From 164033cc935acb6884a194d04ad88180ca1d7ffd Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sun, 11 Oct 2026 11:17:17 +0530 Subject: [PATCH 1/3] feat(chat): incognito chats that learn nothing and are deleted when closed An incognito chat saves no memories (no fact extraction, no memory flush, memory_remember and skill_save refuse), raises no chat.turn_completed, and is left out of summaries, keyword search, the user model and skill review. The window deletes it when the user leaves it; the engine deletes leftovers at start and shutdown and idle ones after chat.incognito_delete_after_minutes. Reading memory follows chat.incognito_uses_memory. Closes #154 --- CHANGELOG.md | 5 + desktop/src/App.tsx | 19 +- .../src/components/shell/CommandPalette.tsx | 11 +- desktop/src/components/shell/Sidebar.tsx | 41 ++-- desktop/src/features/chat/ChatPage.tsx | 45 +++- desktop/src/features/chat/Incognito.tsx | 57 +++++ desktop/src/features/settings/sections.ts | 2 +- desktop/src/lib/api.ts | 3 +- desktop/src/lib/events.ts | 9 + desktop/src/lib/types.ts | 11 + desktop/src/stores/chat.ts | 42 +++- desktop/src/styles/globals.css | 3 + docs/API.md | 33 ++- sentient/agent/incognito.py | 54 +++++ sentient/agent/loop.py | 64 +++++- sentient/app.py | 3 + sentient/config/schema.py | 12 ++ sentient/evolution/service.py | 5 + sentient/gateway/app.py | 2 +- sentient/gateway/routes/core.py | 17 +- sentient/memory/episodic.py | 8 +- sentient/memory/usermodel.py | 3 +- sentient/store/db.py | 32 ++- sentient/store/schema.sql | 3 +- sentient/tools/base.py | 12 ++ sentient/tools/builtin/memory_tool.py | 22 +- sentient/tools/builtin/skills_tool.py | 6 +- tests/test_incognito.py | 201 ++++++++++++++++++ 28 files changed, 655 insertions(+), 70 deletions(-) create mode 100644 desktop/src/features/chat/Incognito.tsx create mode 100644 sentient/agent/incognito.py create mode 100644 tests/test_incognito.py diff --git a/CHANGELOG.md b/CHANGELOG.md index afc5d882..e6b6ae84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- **Incognito chats:** start one from the button next to New chat, the command palette or Ctrl+Shift+N + (Cmd+Shift+N on a Mac). Nothing you say in it is remembered: Sentient saves no memories from it, learns no skills + from it, leaves it out of what it learns about you and of chat search, and deletes it when you leave it or close + Sentient (or after 30 minutes without a message). The chat has a tinted header and an Incognito label so you always + know. It can still use what Sentient already knows about you; turn that off in Settings > General > Conversations. - **One local model job at a time, your chats first:** a local Ollama model now works on one thing at a time, so background work no longer pushes it off the graphics card and slows your chat to a crawl. Your chat always goes next; tasks, suggestions, memory upkeep and chat titles wait while you are chatting (and for 30 seconds after), and diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index f467dd08..9c00b66f 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,6 +1,6 @@ import { QueryClientProvider } from '@tanstack/react-query' import { MotionConfig } from 'motion/react' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { HashRouter, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from 'react-router' import { toast, Toaster } from 'sonner' import { AppShell } from '@/components/shell/AppShell' @@ -30,7 +30,7 @@ import { installDemoData } from '@/lib/demo' import { installDomainEvents } from '@/lib/events' import { queryClient } from '@/lib/queryClient' import { resolveTheme } from '@/lib/theme' -import { installChatEvents } from '@/stores/chat' +import { installChatEvents, isIncognito, useChat } from '@/stores/chat' import { useConnection } from '@/stores/connection' import { useNotificationStore } from '@/stores/notifications' import { useUI } from '@/stores/ui' @@ -55,6 +55,7 @@ export function App() { + }> }> @@ -178,6 +179,19 @@ function BootstrapGate() { ) } +/** Leaving an incognito chat closes it: it is deleted (#154). The engine deletes leftovers when Sentient closes. */ +function IncognitoCleanup() { + const location = useLocation() + const current = location.pathname.match(/^\/chat\/([^/?#]+)/)?.[1] ?? null + const previous = useRef(null) + useEffect(() => { + const prev = previous.current + previous.current = current + if (prev && prev !== current && isIncognito(prev)) useChat.getState().closeIncognito(prev) + }, [current]) + return null +} + function SmokeReporter() { useSmokeReady(true) return null @@ -224,6 +238,7 @@ function GlobalCommands() { useHotkey('mod+k', () => useUI.getState().setPaletteOpen(!useUI.getState().paletteOpen)) useHotkey('mod+n', () => navigate('/chat', { state: { fresh: Date.now() } })) + useHotkey('mod+shift+n', () => navigate('/chat', { state: { fresh: Date.now(), incognito: true } })) useHotkey('mod+,', () => navigate('/settings')) useHotkey('mod+b', () => useUI.getState().toggleSidebar()) return null diff --git a/desktop/src/components/shell/CommandPalette.tsx b/desktop/src/components/shell/CommandPalette.tsx index 1d2ad4f5..59f90248 100644 --- a/desktop/src/components/shell/CommandPalette.tsx +++ b/desktop/src/components/shell/CommandPalette.tsx @@ -15,6 +15,7 @@ import { IconSearch, IconSettings, IconSparkles, + IconSpy, IconSun, type Icon } from '@tabler/icons-react' @@ -90,6 +91,14 @@ function PaletteBody({ close }: { close: () => void }) { go('/chat', { fresh: Date.now() })} shortcut={`${modKey}+N`}> New chat + go('/chat', { fresh: Date.now(), incognito: true })} + shortcut={`${modKey}+Shift+N`} + keywords={['incognito', 'private', 'secret', 'forget']} + > + New incognito chat + go('/tasks?compose=1')} keywords={['task', 'schedule', 'recurring', 'automation']}> New task @@ -160,7 +169,7 @@ function PaletteBody({ close }: { close: () => void }) { {!!sessions.data?.length && ( - {sessions.data.slice(0, 30).map((s) => ( + {sessions.data.filter((s) => !s.incognito).slice(0, 30).map((s) => ( go(`/chat/${s.id}`)}> {s.title?.trim() || 'New chat'} diff --git a/desktop/src/components/shell/Sidebar.tsx b/desktop/src/components/shell/Sidebar.tsx index cb891c5b..9405066b 100644 --- a/desktop/src/components/shell/Sidebar.tsx +++ b/desktop/src/components/shell/Sidebar.tsx @@ -13,6 +13,7 @@ import { IconSearch, IconSettings, IconSparkles, + IconSpy, IconTrash, IconUserHeart, IconX, @@ -88,18 +89,29 @@ export function Sidebar() { className="mb-1.5" /> ) : ( - +
+ + } + onClick={() => navigate('/chat', { state: { fresh: Date.now(), incognito: true } })} + className="size-8.5" + /> +
)} {NAV.map((item) => ( @@ -149,9 +161,10 @@ function ChatList() { const filtered = useMemo(() => { if (!sessions) return [] - if (!q) return sessions + const kept = sessions.filter((s) => !s.incognito) // incognito chats never show up in your history (#154) + if (!q) return kept const hitIds = new Set((search.data ?? []).map((h) => h.session_id)) - return sessions.filter((s) => (s.title ?? 'New chat').toLowerCase().includes(q) || hitIds.has(s.id)) + return kept.filter((s) => (s.title ?? 'New chat').toLowerCase().includes(q) || hitIds.has(s.id)) }, [sessions, q, search.data]) const groups = useMemo(() => { diff --git a/desktop/src/features/chat/ChatPage.tsx b/desktop/src/features/chat/ChatPage.tsx index 17cd6266..184c5982 100644 --- a/desktop/src/features/chat/ChatPage.tsx +++ b/desktop/src/features/chat/ChatPage.tsx @@ -1,4 +1,4 @@ -import { IconArrowDown, IconFileUpload, IconMessageOff, IconWorldWww } from '@tabler/icons-react' +import { IconArrowDown, IconFileUpload, IconMessageOff, IconSpy, IconWorldWww } from '@tabler/icons-react' import { AnimatePresence, motion } from 'motion/react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router' @@ -16,6 +16,7 @@ import { Composer } from './Composer' import { RuleProposalCard } from './RuleProposalCard' import { takeScreenShare } from './screenShare' import { EmptyChat, type Suggestion } from './EmptyChat' +import { IncognitoBadge, IncognitoIntro } from './Incognito' import { useAttachments } from './useAttachments' import { UserMessage } from './UserMessage' @@ -23,13 +24,16 @@ export function ChatPage() { const { sessionId } = useParams() const location = useLocation() // `pendingKey`: a message already sent from outside the chat view (push to talk) whose new chat this view shows. - const state = location.state as { fresh?: number; prompt?: { text: string; autoSend?: boolean }; pendingKey?: string } | null + // `incognito` (or `#/chat?incognito=1`): a new incognito chat (#154). + const state = location.state as { fresh?: number; prompt?: { text: string; autoSend?: boolean }; pendingKey?: string; incognito?: boolean } | null + const newIncognito = !sessionId && (!!state?.incognito || /(^|[?&])incognito=1\b/.test(location.search)) return ( ) } @@ -37,11 +41,13 @@ export function ChatPage() { function ChatView({ sessionId, initialPrompt, - initialPendingKey + initialPendingKey, + newIncognito = false }: { sessionId?: string initialPrompt?: { text: string; autoSend?: boolean } initialPendingKey?: string + newIncognito?: boolean }) { const navigate = useNavigate() const [pendingKey, setPendingKey] = useState(initialPendingKey ?? null) @@ -69,6 +75,9 @@ function ChatView({ const assistantName = bootstrap.data?.assistant.name ?? 'Sentient' const showThinking = config.data?.chat.show_thinking ?? true const session = sessions.data?.find((s) => s.id === sessionId) + const knownIncognito = useChat((s) => (sessionId ? !!s.incognito[sessionId] : false)) + const incognito = newIncognito || knownIncognito || !!session?.incognito || !!liveSession?.request.incognito + const usesMemory = config.data?.chat.incognito_uses_memory ?? true // A brand-new chat navigates to its session once the engine assigns an id. useEffect(() => { @@ -86,10 +95,10 @@ function ChatView({ const onSend = useCallback( async (text: string, files: AttachmentView[], model: string | undefined) => { - const key = await send(sessionId ?? null, { text, attachments: files, model }) + const key = await send(sessionId ?? null, { text, attachments: files, model, incognito: !sessionId && newIncognito }) if (!sessionId) setPendingKey(key) }, - [send, sessionId] + [send, sessionId, newIncognito] ) const onPick = (s: Pick) => { @@ -151,11 +160,17 @@ function ChatView({ }} > {!empty && ( -
+
+ {incognito && }

- {session?.title?.trim() || (streaming ? 'New chat' : 'Chat')} + {session?.title?.trim() || (incognito ? 'Incognito chat' : streaming ? 'New chat' : 'Chat')}

- + {incognito ? : } {streaming && {assistantName} is replying…} @@ -166,7 +181,11 @@ function ChatView({ {empty ? (
- + {newIncognito ? ( + + ) : ( + + )}
+ {incognito && ( +

+ + Incognito: nothing here is remembered, and the chat is deleted when you leave it. +

+ )} + + + +

Incognito chat

+
    + {incognitoLines(usesMemory).map(({ icon: Icon, text }) => ( +
  • + + {text} +
  • + ))} +
+
+ ) +} + +/** The tinted label in an incognito chat's header. */ +export function IncognitoBadge({ className }: { className?: string }) { + return ( + + + Incognito + + ) +} diff --git a/desktop/src/features/settings/sections.ts b/desktop/src/features/settings/sections.ts index 9eb4d545..5c04f16f 100644 --- a/desktop/src/features/settings/sections.ts +++ b/desktop/src/features/settings/sections.ts @@ -35,7 +35,7 @@ export const SETTINGS_SECTIONS: SettingsSectionMeta[] = [ label: 'General', description: 'Names, timezone, language, appearance, startup and shortcuts.', icon: IconSettings, - keywords: ['name', 'timezone', 'location', 'language', 'theme', 'dark', 'light', 'accent', 'color', 'login', 'startup', 'tray', 'shortcut', 'hotkey', 'keyboard', 'screen', 'share', 'screenshot', 'window', 'region'] + keywords: ['name', 'timezone', 'location', 'language', 'theme', 'dark', 'light', 'accent', 'color', 'login', 'startup', 'tray', 'shortcut', 'hotkey', 'keyboard', 'screen', 'share', 'screenshot', 'window', 'region', 'incognito', 'private'] }, { id: 'models', diff --git a/desktop/src/lib/api.ts b/desktop/src/lib/api.ts index 0c2435f6..0ea75f1c 100644 --- a/desktop/src/lib/api.ts +++ b/desktop/src/lib/api.ts @@ -424,7 +424,8 @@ export const api = { sessions: { list: (limit = 100) => http.get('/api/sessions', { query: { limit } }), - create: () => http.post<{ session_id: string }>('/api/sessions'), + create: (opts: { incognito?: boolean } = {}) => + http.post<{ session_id: string }>('/api/sessions', undefined, { query: opts.incognito ? { incognito: true } : undefined }), rename: (id: string, title: string) => http.patch(`/api/sessions/${enc(id)}`, { title }), delete: (id: string) => http.delete(`/api/sessions/${enc(id)}`), messages: (id: string, limit = 500) => diff --git a/desktop/src/lib/events.ts b/desktop/src/lib/events.ts index a7d3b06c..7b95ce23 100644 --- a/desktop/src/lib/events.ts +++ b/desktop/src/lib/events.ts @@ -16,6 +16,7 @@ * memory.updated -> invalidate ['memories'] * skill.updated -> invalidate ['skills'] * session.updated -> rename in ['sessions'] + * session.deleted -> remove from ['sessions'] and refetch its transcript (an incognito chat that was closed) * rule_proposal.updated -> refetch that chat's ['rule-proposals'] ("Make this a rule?" cards) * config.updated -> invalidate config/bootstrap/model presets/ChatGPT sign-in (+ secrets/providers) * voice.state -> ['voice', 'state'] @@ -182,6 +183,14 @@ export function installDomainEvents(qc: QueryClient): () => void { if (!found) void qc.invalidateQueries({ queryKey: qk.sessions }) }) ) + offs.push( + live.onDomain('session.deleted', (e) => { + const { session_id } = e.data + qc.setQueryData(qk.sessions, (old) => old?.filter((s) => s.id !== session_id)) + // an open chat that was deleted (an incognito chat left idle) shows "This chat doesn't exist anymore" + void qc.invalidateQueries({ queryKey: qk.messages(session_id) }) + }) + ) offs.push( live.onDomain('rule_proposal.updated', (e) => { diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index 676ae411..778f3e23 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -151,6 +151,9 @@ export interface ChatConfig { compress_after_messages: number show_thinking: boolean auto_title: boolean + /** Incognito chats can use what Sentient already knows (#154). Nothing from them is remembered either way. */ + incognito_uses_memory?: boolean + incognito_delete_after_minutes?: number } export type ApprovalMode = 'off' | 'ask' | 'always' @@ -363,6 +366,8 @@ export interface Session { channel: string created_at: ISODate updated_at: ISODate + /** Nothing is learned from it and it is deleted when closed (#154). */ + incognito?: boolean [key: string]: unknown } @@ -418,6 +423,8 @@ export interface ChatRequest { attachments?: string[] model?: string channel?: string + /** Start a new incognito chat (#154); ignored with `session_id`. */ + incognito?: boolean } export type ApprovalDecision = 'allow' | 'allow_session' | 'deny' @@ -870,6 +877,8 @@ export interface DomainEventMap { 'memory.updated': MemoryUpdatedData 'skill.updated': { name: string; state: SkillState } 'session.updated': { session_id: string; title: string } + /** A chat was deleted: by the user, or an incognito chat that was closed or left idle (#154). */ + 'session.deleted': { session_id: string; reason?: 'incognito' } 'rule_proposal.updated': RuleProposal 'config.updated': { sections: string[] } 'voice.state': { state: VoiceState } @@ -910,6 +919,8 @@ export type ClientMessage = model?: string client_id?: string channel?: string + /** Start a new incognito chat (#154); ignored with `session_id`. */ + incognito?: boolean } | { type: 'chat.cancel'; session_id: string } | { type: 'chat.steer'; session_id: string; text: string } diff --git a/desktop/src/stores/chat.ts b/desktop/src/stores/chat.ts index 4fedbc66..ed6257d5 100644 --- a/desktop/src/stores/chat.ts +++ b/desktop/src/stores/chat.ts @@ -21,6 +21,8 @@ export interface ChatRequestDraft { text: string attachments?: AttachmentView[] model?: string + /** Start a new incognito chat (#154). Only used for a chat that has no id yet. */ + incognito?: boolean } export interface LiveSession { @@ -49,12 +51,16 @@ interface ChatState { resolved: Record /** session_id -> how full the model's context was after its latest call (#131). Kept after the turn ends. */ context: Record + /** Incognito chats started in this window (#154), so the view and the cleanup know before the list refetches. */ + incognito: Record send: (sessionId: string | null, draft: ChatRequestDraft) => Promise cancel: (sessionId: string) => void retry: (sessionId: string) => Promise respondApproval: (sessionId: string, approvalId: string, decision: ApprovalDecision) => Promise handleEvent: (ev: ChatEvent) => void dropLive: (key: string) => void + /** Close an incognito chat: stop its reply and delete it (#154). */ + closeIncognito: (sessionId: string) => void } const pendingKey = (clientId: string) => `pending:${clientId}` @@ -93,6 +99,7 @@ export const useChat = create((set, get) => ({ live: {}, resolved: {}, context: {}, + incognito: {}, send: async (sessionId, draft) => { const clientId = uid() @@ -127,7 +134,8 @@ export const useChat = create((set, get) => ({ const attachments = (draft.attachments ?? []).map((a) => a.name) const wsOk = live.isOpen || (await live.waitOpen(1500)) - if (wsOk && live.send({ type: 'chat.send', session_id: sessionId ?? undefined, text: draft.text, attachments, model: draft.model, client_id: clientId, channel: 'desktop' })) { + const incognito = !sessionId && draft.incognito ? true : undefined + if (wsOk && live.send({ type: 'chat.send', session_id: sessionId ?? undefined, text: draft.text, attachments, model: draft.model, client_id: clientId, channel: 'desktop', incognito })) { return key } @@ -137,7 +145,7 @@ export const useChat = create((set, get) => ({ void (async () => { let currentKey = key try { - for await (const ev of api.chat.stream({ text: draft.text, session_id: sessionId ?? undefined, attachments, model: draft.model, channel: 'desktop' }, abort.signal)) { + for await (const ev of api.chat.stream({ text: draft.text, session_id: sessionId ?? undefined, attachments, model: draft.model, channel: 'desktop', incognito }, abort.signal)) { if (ev.type === 'session') { get().handleEvent({ ...ev, client_id: clientId }) currentKey = ev.session_id @@ -200,15 +208,18 @@ export const useChat = create((set, get) => ({ if (pending && pk) { const { [pk]: _moved, ...rest } = state.live void _moved + const incognito = !!pending.request.incognito set({ live: { ...rest, [ev.session_id]: { ...pending, key: ev.session_id, sessionId: ev.session_id, user: { ...pending.user, pending: false } } }, - resolved: { ...state.resolved, [pending.clientId]: ev.session_id } + resolved: { ...state.resolved, [pending.clientId]: ev.session_id }, + incognito: incognito ? { ...state.incognito, [ev.session_id]: true } : state.incognito }) - // Show the new chat in the sidebar right away. + // Show the new chat in the sidebar right away (an incognito chat is kept out of the list). + const now = new Date().toISOString() queryClient.setQueryData(qk.sessions, (old) => old?.some((s) => s.id === ev.session_id) ? old - : [{ id: ev.session_id, title: null, channel: 'desktop', created_at: new Date().toISOString(), updated_at: new Date().toISOString() }, ...(old ?? [])] + : [{ id: ev.session_id, title: null, channel: 'desktop', created_at: now, updated_at: now, incognito }, ...(old ?? [])] ) } else if (state.live[ev.session_id]) { set(patchLive(state, ev.session_id, (l) => ({ ...l, user: { ...l.user, pending: false } }))) @@ -319,9 +330,30 @@ export const useChat = create((set, get) => ({ if (!removed) return removed.user.attachments.forEach((a) => a.previewUrl && URL.revokeObjectURL(a.previewUrl)) set({ live: rest }) + }, + + closeIncognito: (sessionId) => { + get().cancel(sessionId) + get().dropLive(sessionId) + const { [sessionId]: _gone, ...incognito } = get().incognito + void _gone + const { [sessionId]: _meter, ...context } = get().context + void _meter + set({ incognito, context }) + queryClient.setQueryData(qk.sessions, (old) => old?.filter((s) => s.id !== sessionId)) + queryClient.removeQueries({ queryKey: qk.messages(sessionId) }) + // the engine also deletes it at shutdown, at the next start and after a while without messages + void api.sessions.delete(sessionId).catch(() => undefined) } })) +/** True when `sessionId` is an incognito chat this window knows about (#154). */ +export function isIncognito(sessionId: string | null | undefined): boolean { + if (!sessionId) return false + if (useChat.getState().incognito[sessionId]) return true + return !!queryClient.getQueryData(qk.sessions)?.find((s) => s.id === sessionId)?.incognito +} + function lastUserDraft(sessionId: string): ChatRequestDraft | null { const rows = queryClient.getQueryData(qk.messages(sessionId)) ?? [] const lastUser = [...rows].reverse().find((r) => r.role === 'user') diff --git a/desktop/src/styles/globals.css b/desktop/src/styles/globals.css index 3f24c69e..74757067 100644 --- a/desktop/src/styles/globals.css +++ b/desktop/src/styles/globals.css @@ -30,6 +30,7 @@ --color-warning: var(--warning); --color-danger: var(--danger); --color-info: var(--info); + --color-incognito: var(--incognito); } @theme { @@ -173,6 +174,7 @@ --warning: #fbbf24; --danger: #f87171; --info: #60a5fa; + --incognito: #a78bfa; color-scheme: dark; } @@ -195,6 +197,7 @@ --warning: #b45309; --danger: #dc2626; --info: #2563eb; + --incognito: #6d28d9; --shadow-pop: 0 12px 32px -10px rgb(15 15 25 / 0.18), 0 2px 6px rgb(15 15 25 / 0.06); color-scheme: light; } diff --git a/docs/API.md b/docs/API.md index 3605fa06..b1afbaff 100644 --- a/docs/API.md +++ b/docs/API.md @@ -17,7 +17,7 @@ If you need to change a shape, change this file in the same edit. ### Client → server | type | fields | meaning | |---|---|---| -| `chat.send` | `session_id?`, `text`, `attachments?: string[]` (file names from `/api/files`), `model?` (override primary for this turn), `client_id?` | start a turn; omit `session_id` for a new chat | +| `chat.send` | `session_id?`, `text`, `attachments?: string[]` (file names from `/api/files`), `model?` (override primary for this turn), `client_id?`, `incognito?: bool` | start a turn; omit `session_id` for a new chat. `incognito: true` makes that new chat an incognito chat (section 2); ignored with `session_id` | | `chat.steer` | `session_id`, `text`, `client_id?` | add to the reply in progress (section 10); without a running reply same as `chat.send` | | `chat.cancel` | `session_id` | stop the reply in progress | | `approval.respond` | `approval_id`, `decision: "allow"\|"allow_session"\|"deny"` | answer an approval request | @@ -79,6 +79,7 @@ Envelope: `{"type": "task.updated", "data": {...}, "ts": "..."}` | `memory.updated` | `{action: "ADD"\|"UPDATE"\|"DELETE", id, content?, status?: "pending"}`; `status: "pending"` means the memory waits in the review inbox (section 7); bulk changes (import, delete by source, expiry purge) send `id: null` plus `source?`/`reason?` and `count`. Review sends `reason: "approved"` (ADD, or DELETE with `merged_into` when the same words were already remembered), `"discarded"` (DELETE) and `"review_expired"` (bulk DELETE) | | `skill.updated` | `{name, state: "active"\|"pending_review"\|"stale"\|"archived"\|"rejected"\|"deleted"}` | | `session.updated` | `{session_id, title}` | +| `session.deleted` | `{session_id, reason?: "incognito"}`: a chat was deleted (`DELETE /api/sessions/{id}`, or an incognito chat closed by the engine, section 2) | | `rule_proposal.updated` | **RuleProposal** (section 2, rules from chat): a new "Make this a rule?" card, or one the user answered | | `config.updated` | `{sections: string[]}` | | `voice.state` | `{state, session_id}` (mirrors voice socket for other windows) | @@ -193,13 +194,15 @@ accept or loosen a rule. "From your message on ". An entry is dropped when its rule is changed or removed. ### Sessions (chats) -- `GET /api/sessions?limit=100` → `[{id, title, channel, created_at, updated_at, untrusted, visited_hosts}]` newest first +- `GET /api/sessions?limit=100` → `[{id, title, channel, created_at, updated_at, untrusted, visited_hosts, incognito}]` newest first (`untrusted`: the app whose content the chat read, e.g. `"Gmail"`, `""` when clean, `null` before its first turn; - `visited_hosts`: list of web hosts it loaded, or `null`; section 10) -- `POST /api/sessions` → `{session_id}` + `visited_hosts`: list of web hosts it loaded, or `null`; section 10; `incognito`: bool, below. The window keeps + incognito chats out of its chat list) +- `POST /api/sessions?incognito=false` → `{session_id}` - `PATCH /api/sessions/{id}` `{title}` → `{ok}` - `DELETE /api/sessions/{id}` → `{ok}` -- `GET /api/sessions/{id}/messages?limit=500` → raw transcript rows, oldest first: +- `GET /api/sessions/{id}/messages?limit=500` → raw transcript rows, oldest first (404 when the chat doesn't exist, e.g. a + closed incognito chat): ```json {"id": "...", "role": "user|assistant|tool", "content": "...", "thinking": "...|null", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "memory_recall", "arguments": "{\"query\":\"sister\"}"}}], @@ -217,8 +220,24 @@ accept or loosen a rule. first mention wins, at most 40. Only rows of a memory tool's result that fit in the run's tool result limit (`LoopResult.tool_result_limit`, section 10: the part the model read) count. Pending memories (section 7, review) never appear. Task runs record the same list (section 4, Run `memory_sources`). -- `GET /api/sessions/search?q=` → `[{session_id, message_id, role, snippet, created_at}]` -- `POST /api/chat` NDJSON fallback of the WebSocket turn: body `{text, session_id?, attachments?, model?}`; lines are the chat events above, first line `{type: "session", session_id}`. +- `GET /api/sessions/search?q=` → `[{session_id, message_id, role, snippet, created_at}]` (never incognito chats) +- `POST /api/chat` NDJSON fallback of the WebSocket turn: body `{text, session_id?, attachments?, model?, incognito?}`; lines are the chat events above, first line `{type: "session", session_id}`. +- **Incognito chats** (#154). A chat created with `incognito: true` (`chat.send`, `POST /api/chat`, + `POST /api/sessions?incognito=true`) can't be switched later, and the engine enforces: + - nothing is learned from it: no fact extraction after a turn, no memory flush when it is compressed, and no + `chat.turn_completed` event, so skill review and repair, the user model and dreaming never see it. Its skill + views don't count as skill use. + - `memory_remember` and `skill_save` are not offered, and refuse with an error when called in it (also from a + helper it started). Nothing is stored, not even held for review. + - keyword search (`GET /api/sessions/search`, `memory_search_history`), conversation summaries, + `history_time_search`, the user model's evidence and skill review leave it out. + - reading memory follows `chat.incognito_uses_memory` (default `true`): recalled facts, user-model insights and + the memory tools work as usual, and the reply's `memory_sources` list them. Off, its prompt has no profile, + memory file, daily notes, facts or insights, and the memory look-up tools are not offered and return nothing. + The prompt also tells the model the chat is incognito. + - it is deleted: the window deletes it when the user leaves it; the engine deletes every incognito chat at start + and shutdown, and one with no new message for `chat.incognito_delete_after_minutes` (default 30) within a minute. + Each engine deletion publishes `session.deleted` with `reason: "incognito"`. - `POST /api/approvals` `{approval_id, decision}` → `{resolved}` ### Files (attachments + assistant outputs) diff --git a/sentient/agent/incognito.py b/sentient/agent/incognito.py new file mode 100644 index 00000000..0802f9dc --- /dev/null +++ b/sentient/agent/incognito.py @@ -0,0 +1,54 @@ +"""Incognito chats (#154): deleted when they are closed. + +The window deletes an incognito chat when the user leaves it. This service covers the rest: chats left over when +Sentient was closed are deleted at the next start (and at shutdown), and a chat with no new message for +``chat.incognito_delete_after_minutes`` is deleted by a sweep. What the engine refuses to learn from an incognito chat +is enforced where it happens (``Agent.run_turn``, the memory and skill tools, summaries, the user model, skill review). +""" + +from __future__ import annotations + +import contextlib +import logging +from datetime import UTC, datetime, timedelta + +from sentient.services import Service + +log = logging.getLogger(__name__) + +SWEEP_EVERY_S = 60 + + +class IncognitoSweeper(Service): + name = "incognito" + + async def start(self) -> None: + await self.purge() # chats from an earlier run were closed with it + if self.app.enable_background: + self.run_every(SWEEP_EVERY_S, self.sweep, name="sweep", initial_delay=SWEEP_EVERY_S) + + async def stop(self) -> None: + await super().stop() + with contextlib.suppress(Exception): + await self.purge(skip_running=False) + + async def sweep(self, now: datetime | None = None) -> list[str]: + """Delete incognito chats with no new message for ``chat.incognito_delete_after_minutes``.""" + minutes = self.app.config.chat.incognito_delete_after_minutes + cutoff = (now or datetime.now(UTC)) - timedelta(minutes=minutes) + return await self.purge(cutoff.isoformat()) + + async def purge(self, idle_before: str | None = None, *, skip_running: bool = True) -> list[str]: + """Delete incognito chats (only those idle since ``idle_before`` when given). A chat whose reply is still + running is left for the next sweep unless ``skip_running`` is off. Returns the deleted ids.""" + agent = self.app.agent + deleted: list[str] = [] + for sid in await self.app.store.incognito_sessions(idle_before): + if skip_running and agent is not None and agent.is_running(sid): + continue + await self.app.store.delete_session(sid) + self.app.bus.publish("session.deleted", {"session_id": sid, "reason": "incognito"}) + deleted.append(sid) + if deleted: + log.info("deleted %d incognito chat(s)", len(deleted)) + return deleted diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py index 49161210..8b7c59c5 100644 --- a/sentient/agent/loop.py +++ b/sentient/agent/loop.py @@ -134,6 +134,20 @@ "You called {name} with exactly the same arguments several times and got the same result each time. " "Do not call it again with these arguments. Try something different, or answer the user with what you have." ) +# Incognito chats (#154): tools that would keep something from the chat are never offered or run, and the model is +# told so it does not promise to remember. Without ``chat.incognito_uses_memory`` the memory look-ups go too. +INCOGNITO_LEARNING_TOOLS = frozenset({"memory_remember", "skill_save"}) +INCOGNITO_MEMORY_TOOLS = frozenset({ + "memory_recall", "memory_search_by_source", "memory_search_history", "history_semantic_search", + "history_time_search", "user_model_ask", +}) +INCOGNITO_NOTE = ( + "## Incognito chat\nThis is an incognito chat. Nothing from it is saved to memory, and it is deleted when the " + "user closes it. Never say you will remember something from it." +) +INCOGNITO_NO_MEMORY_NOTE = ( + INCOGNITO_NOTE + " You also do not use what you know about the user here: answer from this chat alone." +) PersistFn = Callable[..., Awaitable[Any]] # policy(tool, effective_risk, arguments) -> refusal message or None (sync or async) PolicyFn = Callable[[Tool, Risk, dict], "str | Awaitable[str | None] | None"] @@ -489,15 +503,24 @@ async def system_prompt(self, user_text: str, channel: str, session: dict | None async def _system_prompt_with_sources( self, user_text: str, channel: str, session: dict | None = None ) -> tuple[str, MemorySources]: - """The system prompt and the memories put into it (recalled facts, user-model insights).""" - facts, (user_context, insights) = await asyncio.gather( - self._recall(user_text), self._user_model_context(user_text) - ) + """The system prompt and the memories put into it (recalled facts, user-model insights). An incognito chat + (#154) reads memory only while ``chat.incognito_uses_memory`` is on, and is told nothing will be kept.""" + incognito = bool((session or {}).get("incognito")) + reads_memory = not incognito or self.config.chat.incognito_uses_memory + if reads_memory: + facts, (user_context, insights) = await asyncio.gather( + self._recall(user_text), self._user_model_context(user_text) + ) + else: + facts, user_context, insights = [], "", [] sources = MemorySources() sources.add_facts(facts) sources.add_insights(insights) + snapshot = self.workspace.snapshot() + if not reads_memory: # only the persona: no profile, memory file or daily notes + snapshot = {k: (v if k == "soul" else "") for k, v in snapshot.items()} prompt = build_system_prompt( - snapshot=self.workspace.snapshot(), + snapshot=snapshot, facts=facts, skills_index=self.skills.prompt_index(), assistant_name=self.config.assistant.name, @@ -510,8 +533,16 @@ async def _system_prompt_with_sources( ) if session and session.get("context_summary"): prompt += "\n\n## Earlier in this conversation\n" + session["context_summary"] + if incognito: + prompt += "\n\n" + (INCOGNITO_NOTE if reads_memory else INCOGNITO_NO_MEMORY_NOTE) return prompt, sources + def _incognito_tools(self, tool_names: list[str] | None) -> list[str]: + """``tool_names`` (None: every offered tool) without the tools an incognito chat must not use (#154).""" + names = tool_names if tool_names is not None else [t.name for t in self.registry.tools()] + left_out = INCOGNITO_LEARNING_TOOLS | (frozenset() if self.config.chat.incognito_uses_memory else INCOGNITO_MEMORY_TOOLS) + return [n for n in names if n not in left_out] + def _read_only_apps(self) -> list[str]: """Names of the connected apps set to Read only that have tools Sentient now can't use (#141), so the model can say why instead of looking for another way.""" @@ -1124,6 +1155,7 @@ def release() -> list[str]: await self.store.touch_session(session_id, title=(user_text or (attachments[0] if attachments else ""))[:60]) session = await self.store.get_session(session_id) + incognito = bool((session or {}).get("incognito")) system, sources = await self._system_prompt_with_sources(user_text, channel, session) history = await self.store.recent_messages(session_id, self.config.chat.history_window) convo = history_to_openai(history) @@ -1150,6 +1182,7 @@ def release() -> list[str]: role = "vision" messages: list[dict] = [{"role": "system", "content": system}, *convo] ctx = self.tool_context(session_id, channel) + ctx.incognito = incognito # outside content read earlier in this chat still counts until a new chat starts (ADR 0018) ctx.untrusted = await self._chat_untrusted(session_id, session) if not ctx.untrusted and any(is_screen_capture(a) for a in attachments): @@ -1172,6 +1205,8 @@ def release() -> list[str]: except Exception as exc: # selection must never break a turn log.warning("tool selection failed, offering all tools: %s", exc) tool_names = None + if incognito: + tool_names = self._incognito_tools(tool_names) async def persist(role_: str, content: str | None, **fields: Any) -> None: mid = await self.store.add_message(session_id, role_, content, **fields) @@ -1229,13 +1264,18 @@ async def persist(role_: str, content: str | None, **fields: Any) -> None: yield Done(content=result.text, message_id=message_id, memory_sources=memory_sources, **ev) said = "\n\n".join([user_text, *result.interjections]).strip() - if self.memory is not None and self.config.memory.extract_after_turn: + # nothing is learned from an incognito chat (#154): no fact extraction, no memory flush, and no + # chat.turn_completed, so skills, the user model and dreaming never see it + if self.memory is not None and self.config.memory.extract_after_turn and not incognito: # a chat that read outside content holds what it learns for the user's review (ADR 0021) + log.info("chat %s: fact extraction started for this turn", session_id) self._spawn(self._extract(said, memory_review.for_context(ctx)), "memory") if self.config.chat.auto_title and session and (session.get("title") or "") == (user_text or "")[:60]: self._spawn(self._auto_title(session_id, user_text, result.text), "titles") - self._spawn(self._maybe_compress(session_id), "memory") - if self.app is not None: + self._spawn(self._maybe_compress(session_id, flush=not incognito), "memory") + if incognito: + log.info("incognito chat %s: nothing learned from this turn", session_id) + elif self.app is not None: self.app.bus.publish( "chat.turn_completed", { @@ -1391,9 +1431,10 @@ async def _flush_memory(self, transcript: str, untrusted: str = "", session_id: except Exception as exc: log.warning("memory flush before compression failed: %s", exc) - async def _maybe_compress(self, session_id: str) -> None: + async def _maybe_compress(self, session_id: str, *, flush: bool = True) -> None: """Hermes-style context compression: fold turns that fell out of the history - window into a running summary injected into the system prompt.""" + window into a running summary injected into the system prompt. ``flush=False`` (an incognito chat) keeps + no facts from the folded turns.""" try: total = await self.store.count_messages(session_id) window = self.config.chat.history_window @@ -1410,7 +1451,8 @@ async def _maybe_compress(self, session_id: str) -> None: if len(older) < 10: return transcript = "\n".join(f"{r['role']}: {(r['content'] or '')[:800]}" for r in older) - await self._flush_memory(transcript, (session or {}).get("untrusted") or "", session_id) + if flush: + await self._flush_memory(transcript, (session or {}).get("untrusted") or "", session_id) prev = (session or {}).get("context_summary") or "" summary = await self.llm.complete_text( "fast", diff --git a/sentient/app.py b/sentient/app.py index 561d07b9..a8948327 100644 --- a/sentient/app.py +++ b/sentient/app.py @@ -26,6 +26,7 @@ from sentient import paths from sentient.agent.approvals import ApprovalBroker from sentient.agent.chat_rules import ChatRules +from sentient.agent.incognito import IncognitoSweeper from sentient.agent.loop import Agent from sentient.agent.subagents import SubagentManager from sentient.browser import BrowserService @@ -113,6 +114,7 @@ def __init__( self.channels = ChannelService(self) self.user_model = UserModelService(self) self.dreaming = DreamingService(self) + self.incognito = IncognitoSweeper(self) # incognito chats are deleted when closed (#154) self._started = False self.stop_state: dict[str, Any] = {"stopped": False, "stopped_at": None, "source": None} # bumped by every stop_all: work queued before it (e.g. a chat message waiting behind a reply) is dropped @@ -127,6 +129,7 @@ def services(self) -> list[Service]: return [ self.notifications, self.integrations, self.subagents, self.sandbox, self.terminal, self.browser, self.nodes, self.tasks, self.proactivity, self.evolution, self.user_model, self.dreaming, self.voice, self.channels, + self.incognito, ] async def notify(self, kind: str, message: str, *, title: str | None = None, payload: dict | None = None) -> dict: diff --git a/sentient/config/schema.py b/sentient/config/schema.py index 7c2881bc..3f1d8cc2 100644 --- a/sentient/config/schema.py +++ b/sentient/config/schema.py @@ -246,6 +246,18 @@ class ChatConfig(BaseModel): parallel_read_tools: bool = Field( True, description="Run look-up tools the model asks for in the same step at the same time." ) + incognito_uses_memory: bool = Field( + True, + description="Incognito chats can use what Sentient already knows about you. Nothing from an incognito chat " + "is ever remembered either way.", + ) + incognito_delete_after_minutes: int = Field( + 30, + ge=1, + le=1440, + description="An incognito chat is deleted when you leave it or close Sentient, or after this many minutes " + "without a new message.", + ) class RuleOrigin(BaseModel): diff --git a/sentient/evolution/service.py b/sentient/evolution/service.py index ad398462..30866e4a 100644 --- a/sentient/evolution/service.py +++ b/sentient/evolution/service.py @@ -246,6 +246,7 @@ async def sessions_due(self, now: datetime, idle_minutes: int | None = None) -> "SELECT s.id, s.updated_at, r.activity_at FROM sessions s" " LEFT JOIN evolution_reviews r ON r.target = 'session:' || s.id" " WHERE s.updated_at <= ? AND s.updated_at >= ? AND (r.activity_at IS NULL OR r.activity_at < s.updated_at)" + " AND s.incognito = 0" # skills are never learned from an incognito chat (#154) " ORDER BY s.updated_at", (cutoff, lookback), ) @@ -335,6 +336,8 @@ async def _record_review(self, target: str, activity_at: str | None, tool_calls: async def review_session(self, session_id: str, *, force: bool = False) -> dict: store = self.app.store + if await store.is_incognito(session_id): # never learned from (#154) + return {"reviewed": False, "decision": "skipped"} target = f"session:{session_id}" prev = await store.fetchone("SELECT activity_at FROM evolution_reviews WHERE target = ?", (target,)) since = (prev["activity_at"] if prev else None) or "" @@ -521,6 +524,8 @@ async def _on_turn(self, data: dict) -> list[str]: if not sid: return [] lib, store = self.app.skills, self.app.store + if await store.is_incognito(sid): # an incognito chat never shapes skills (#154) + return [] proposed: list[str] = [] prev = self._last_skill_turn.pop(sid, None) user_text = str(data.get("user_text") or "") diff --git a/sentient/gateway/app.py b/sentient/gateway/app.py index e0a12350..106e660c 100644 --- a/sentient/gateway/app.py +++ b/sentient/gateway/app.py @@ -159,7 +159,7 @@ async def forward_bus() -> None: await send({"type": "steer_ack", "session_id": session_id, "queued": False, "client_id": msg.get("client_id")}) session_id = session_id or await s.store.create_session( - channel=str(msg.get("channel", "desktop")) + channel=str(msg.get("channel", "desktop")), incognito=msg.get("incognito") is True ) await send({"type": "session", "session_id": session_id, "client_id": msg.get("client_id")}) chain = turns.setdefault(session_id, []) diff --git a/sentient/gateway/routes/core.py b/sentient/gateway/routes/core.py index 12bcf0f0..fd04c871 100644 --- a/sentient/gateway/routes/core.py +++ b/sentient/gateway/routes/core.py @@ -241,12 +241,13 @@ async def sessions(request: Request, limit: int = 100): row["visited_hosts"] = json.loads(raw) if raw else None except (TypeError, ValueError): row["visited_hosts"] = None + row["incognito"] = bool(row.get("incognito")) return rows @router.post("/api/sessions", dependencies=_auth) -async def create_session(request: Request, channel: str = "desktop"): - sid = await get_core(request).store.create_session(channel=channel) +async def create_session(request: Request, channel: str = "desktop", incognito: bool = False): + sid = await get_core(request).store.create_session(channel=channel, incognito=incognito) return {"session_id": sid} @@ -284,13 +285,18 @@ async def rename_session(request: Request, session_id: str, body: RenameBody): @router.delete("/api/sessions/{session_id}", dependencies=_auth) async def delete_session(request: Request, session_id: str): - await get_core(request).store.delete_session(session_id) + s = get_core(request) + await s.store.delete_session(session_id) + s.bus.publish("session.deleted", {"session_id": session_id}) return {"ok": True} @router.get("/api/sessions/{session_id}/messages", dependencies=_auth) async def session_messages(request: Request, session_id: str, limit: int = 500): - return await get_core(request).store.recent_messages(session_id, limit) + s = get_core(request) + if await s.store.get_session(session_id) is None: # deleted (an incognito chat that was closed, #154) + raise HTTPException(404, "no such chat") + return await s.store.recent_messages(session_id, limit) # ----------------------------------------------------------------------------- rules from chat (#130) @@ -324,6 +330,7 @@ class ChatBody(BaseModel): channel: str = "desktop" attachments: list[str] = [] model: str | None = None + incognito: bool = False # a new chat (no session_id) that learns nothing and is deleted when closed (#154) @router.post("/api/chat", dependencies=_auth) @@ -334,7 +341,7 @@ async def chat(request: Request, body: ChatBody): # a reply is already running for this chat: the text steers it (docs/API.md section 10) ack = {"type": "steer_ack", "session_id": body.session_id, "queued": True} return StreamingResponse(iter([json.dumps(ack) + "\n"]), media_type="application/x-ndjson") - session_id = body.session_id or await s.store.create_session(channel=body.channel) + session_id = body.session_id or await s.store.create_session(channel=body.channel, incognito=body.incognito) async def gen(): yield json.dumps({"type": "session", "session_id": session_id}) + "\n" diff --git a/sentient/memory/episodic.py b/sentient/memory/episodic.py index 93f25c68..a6b0a990 100644 --- a/sentient/memory/episodic.py +++ b/sentient/memory/episodic.py @@ -21,7 +21,7 @@ from sentient.llm.provider import LLMProvider from sentient.memory import prompts from sentient.memory.vectors import VecTable, pack -from sentient.store.db import Store, new_id, now_iso +from sentient.store.db import NOT_INCOGNITO, Store, new_id, now_iso log = logging.getLogger(__name__) @@ -81,7 +81,8 @@ async def pending_chunks(self, now: datetime | None = None) -> list[tuple[str, l rows = await self.store.fetchall( "SELECT id, session_id, role, content, attachments, created_at FROM messages" " WHERE summarized = 0 AND role IN ('user', 'assistant') AND content IS NOT NULL AND content != ''" - " AND created_at < ? ORDER BY session_id, created_at, rowid", + f" AND created_at < ? AND {NOT_INCOGNITO.format(col='messages.session_id')}" # never summarized (#154) + " ORDER BY session_id, created_at, rowid", (cutoff,), ) by_session: dict[str, list[dict]] = {} @@ -214,7 +215,8 @@ async def messages_between(self, start: str, end: str, limit: int = 200) -> list "SELECT m.session_id, m.role, m.content, m.created_at, s.title FROM messages m" " LEFT JOIN sessions s ON s.id = m.session_id" " WHERE m.created_at >= ? AND m.created_at <= ? AND m.role IN ('user', 'assistant')" - " AND m.content IS NOT NULL AND m.content != '' ORDER BY m.created_at LIMIT ?", + " AND m.content IS NOT NULL AND m.content != ''" + f" AND {NOT_INCOGNITO.format(col='m.session_id')} ORDER BY m.created_at LIMIT ?", (s, e, limit), ) return [dict(r) for r in rows] diff --git a/sentient/memory/usermodel.py b/sentient/memory/usermodel.py index 71cc684d..68e32929 100644 --- a/sentient/memory/usermodel.py +++ b/sentient/memory/usermodel.py @@ -605,7 +605,8 @@ async def _gather(self, since: str) -> tuple[dict[str, dict], dict[str, str], di rows = await store.fetchall( "SELECT m.id, m.content, m.created_at, s.untrusted FROM messages m LEFT JOIN sessions s" " ON s.id = m.session_id WHERE m.role = 'user' AND m.content IS NOT NULL" - " AND m.content != '' AND m.created_at > ? ORDER BY m.created_at DESC, m.rowid DESC LIMIT ?", + " AND m.content != '' AND m.created_at > ? AND COALESCE(s.incognito, 0) = 0" # never incognito (#154) + " ORDER BY m.created_at DESC, m.rowid DESC LIMIT ?", (since, cfg.recent_messages), ) lines = [] diff --git a/sentient/store/db.py b/sentient/store/db.py index d30b47c5..6eedf71b 100644 --- a/sentient/store/db.py +++ b/sentient/store/db.py @@ -23,6 +23,11 @@ PACKAGE_SCHEMAS = ["tasks", "integrations", "proactivity", "evolution", "voice", "memory", "nodes", "channels", "browser", "sandbox"] +# SQL condition: the chat ``col`` names is not incognito (#154). Learning, summaries and history search use it so an +# incognito chat is never read back. ``.format(col="m.session_id")``. +NOT_INCOGNITO = "NOT EXISTS (SELECT 1 FROM sessions inc WHERE inc.id = {col} AND inc.incognito = 1)" + + def now_iso() -> str: return datetime.now(UTC).isoformat() @@ -64,6 +69,7 @@ async def open(self) -> Store: await self.ensure_column("sessions", "context_upto", "TEXT") await self.ensure_column("sessions", "untrusted", "TEXT") await self.ensure_column("sessions", "visited_hosts", "TEXT") + await self.ensure_column("sessions", "incognito", "INTEGER NOT NULL DEFAULT 0") await self.ensure_column("messages", "attachments", "TEXT") await self.ensure_column("messages", "interjection", "INTEGER NOT NULL DEFAULT 0") await self.ensure_column("messages", "memory_sources", "TEXT") @@ -124,15 +130,32 @@ async def set_meta(self, key: str, value: str) -> None: ) # ------------------------------------------------------------------ sessions & messages - async def create_session(self, channel: str = "web", title: str | None = None) -> str: + async def create_session(self, channel: str = "web", title: str | None = None, *, incognito: bool = False) -> str: sid = new_id() ts = now_iso() await self.execute( - "INSERT INTO sessions(id, title, channel, created_at, updated_at) VALUES(?,?,?,?,?)", - (sid, title, channel, ts, ts), + "INSERT INTO sessions(id, title, channel, created_at, updated_at, incognito) VALUES(?,?,?,?,?,?)", + (sid, title, channel, ts, ts, int(bool(incognito))), ) return sid + async def is_incognito(self, session_id: str | None) -> bool: + """True for an incognito chat (#154): nothing is learned from it and it is deleted when closed.""" + if not session_id: + return False + row = await self.fetchone("SELECT incognito FROM sessions WHERE id = ?", (session_id,)) + return bool(row and row["incognito"]) + + async def incognito_sessions(self, idle_before: str | None = None) -> list[str]: + """Ids of incognito chats, or only those last used before ``idle_before`` (ISO time).""" + if idle_before is None: + rows = await self.fetchall("SELECT id FROM sessions WHERE incognito = 1") + else: + rows = await self.fetchall( + "SELECT id FROM sessions WHERE incognito = 1 AND updated_at < ?", (idle_before,) + ) + return [r["id"] for r in rows] + async def touch_session(self, session_id: str, title: str | None = None) -> None: if title: await self.execute( @@ -208,9 +231,10 @@ async def count_messages(self, session_id: str) -> int: return int(row["n"]) if row else 0 async def search_messages(self, query: str, limit: int = 10) -> list[dict]: + """Keyword search over past chats. Incognito chats are never found (#154).""" rows = await self.fetchall( "SELECT m.* FROM messages_fts f JOIN messages m ON m.rowid = f.rowid" - " WHERE messages_fts MATCH ? ORDER BY rank LIMIT ?", + f" WHERE messages_fts MATCH ? AND {NOT_INCOGNITO.format(col='m.session_id')} ORDER BY rank LIMIT ?", (query, limit), ) return [dict(r) for r in rows] diff --git a/sentient/store/schema.sql b/sentient/store/schema.sql index 48f4984c..61c3daa2 100644 --- a/sentient/store/schema.sql +++ b/sentient/store/schema.sql @@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS sessions ( context_summary TEXT, -- running summary of turns older than the history window context_upto TEXT, -- created_at of the last message folded into the summary untrusted TEXT, -- app whose content this chat read ("Gmail"); sends then ask (ADR 0018) - visited_hosts TEXT -- JSON list of web hosts this chat loaded (ADR 0018) + visited_hosts TEXT, -- JSON list of web hosts this chat loaded (ADR 0018) + incognito INTEGER NOT NULL DEFAULT 0 -- 1: nothing is learned from it and it is deleted when closed (#154) ); CREATE TABLE IF NOT EXISTS messages ( diff --git a/sentient/tools/base.py b/sentient/tools/base.py index 3edf97bb..8227f151 100644 --- a/sentient/tools/base.py +++ b/sentient/tools/base.py @@ -113,6 +113,18 @@ class ToolContext: untrusted: str = "" # web hosts this run (or chat) already loaded; an address on one of them never asks for carrying data (ADR 0018) visited: set[str] = field(default_factory=set) + # True in an incognito chat (#154): tools that would keep something from it refuse. Tools also check the chat + # itself (``store.is_incognito``), so work it starts (a subagent) is covered too. + incognito: bool = False + + async def is_incognito(self) -> bool: + """True when this run belongs to an incognito chat (#154).""" + if self.incognito: + return True + check = getattr(self.store, "is_incognito", None) + if not self.session_id or not callable(check): + return False + return bool(await check(self.session_id)) @property def call_id(self) -> str | None: diff --git a/sentient/tools/builtin/memory_tool.py b/sentient/tools/builtin/memory_tool.py index c2413f87..d393d09b 100644 --- a/sentient/tools/builtin/memory_tool.py +++ b/sentient/tools/builtin/memory_tool.py @@ -14,12 +14,20 @@ from sentient.tools.base import Risk, ToolContext, ToolPlugin, tool from sentient.tools.rules import brings_untrusted +INCOGNITO_REFUSAL = ( + "Not saved: this is an incognito chat, so nothing from it is remembered. Don't tell the user you will remember it." +) HELD_NOTE = ( "Saved for the user to review first, because this run read outside content or nobody asked for it. " "It is not used until they approve it." ) +async def _memory_closed(ctx: ToolContext) -> bool: + """An incognito chat with ``chat.incognito_uses_memory`` off reads nothing Sentient knows (#154).""" + return not ctx.config.chat.incognito_uses_memory and await ctx.is_incognito() + + def _episodic(ctx: ToolContext) -> EpisodicMemory: if ctx.memory is not None and getattr(ctx.memory, "episodic", None) is not None: return ctx.memory.episodic @@ -35,7 +43,7 @@ def _fts_query(text: str, joiner: str) -> str: async def memory_recall(ctx: ToolContext, query: str, limit: int = 8) -> list[dict]: """Search what you remember about the user. Use when a question depends on personal context (their preferences, people, projects, routines) that is not already in your prompt.""" - if ctx.memory is None: + if ctx.memory is None or await _memory_closed(ctx): return [] facts = await ctx.memory.recall(query, top_k=limit) return [ @@ -50,6 +58,8 @@ async def memory_remember(ctx: ToolContext, fact: str) -> dict: Existing facts on the same subject are updated instead of duplicated.""" if ctx.memory is None: return {"error": "memory unavailable"} + if await ctx.is_incognito(): # nothing is kept from an incognito chat (#154) + return {"error": INCOGNITO_REFUSAL} held = review.for_context(ctx) # outside content or unprompted work: the user reviews it first (ADR 0021) if held is not None: held["snippet"] = review.note("", await _last_outside_result(ctx))["snippet"] @@ -128,7 +138,7 @@ async def memory_forget(ctx: ToolContext, fact_id: int) -> dict: async def memory_search_by_source(ctx: ToolContext, query: str, source: str, limit: int = 5) -> list[dict]: """Search remembered facts that came from one source, e.g. "file:resume.pdf", "onboarding", "conversation" or "manual". Use when the user asks what you learned from a specific document.""" - if ctx.memory is None: + if ctx.memory is None or await _memory_closed(ctx): return [] facts = await ctx.memory.search_by_source(query, source, top_k=limit) return [{"id": f["id"], "fact": f["content"], "similarity": f["similarity"]} for f in facts] @@ -138,6 +148,8 @@ async def memory_search_by_source(ctx: ToolContext, query: str, source: str, lim async def memory_search_history(ctx: ToolContext, query: str, limit: int = 8) -> list[dict]: """Keyword search over past conversations. Use when the user refers to something discussed earlier ("that link you sent", "what did we decide about X").""" + if await _memory_closed(ctx): + return [] rows = [] for joiner in (" ", " OR "): q = _fts_query(query, joiner) @@ -157,6 +169,8 @@ async def memory_search_history(ctx: ToolContext, query: str, limit: int = 8) -> async def history_semantic_search(ctx: ToolContext, query: str, limit: int = 5) -> dict: """Meaning-based search of summaries of older conversations. Use for topics, ideas or past decisions when you do not know when they were discussed ("what did we decide about the marketing plan?").""" + if await _memory_closed(ctx): + return {"result": "No relevant conversation summaries found."} hits = await _episodic(ctx).search(query, limit=limit) if not hits: return {"result": "No relevant conversation summaries found."} @@ -173,6 +187,8 @@ async def history_time_search(ctx: ToolContext, start_date: str, end_date: str, """Every message exchanged in a date range. Use when the user asks about a specific day or period ("what did we talk about yesterday afternoon?"). Dates are ISO 8601, e.g. '2026-09-14T00:00:00Z' or '2026-09-14' (a bare end date includes that whole day).""" + if await _memory_closed(ctx): + return {"result": "No messages found in that time period."} try: rows = await _episodic(ctx).messages_between(start_date, end_date, limit=limit) except ValueError: @@ -188,6 +204,8 @@ async def history_time_search(ctx: ToolContext, start_date: str, end_date: str, async def user_model_ask(ctx: ToolContext, question: str) -> dict: """Predict what the user would want or prefer, from the insights and facts you have learned about them. Use before choosing on their behalf ("which option would they pick?", "how do they like reports written?").""" + if await _memory_closed(ctx): + return {"error": "This incognito chat doesn't use what Sentient knows about the user."} app = ctx.extra.get("app") um = getattr(app, "user_model", None) if um is None or not hasattr(um, "ask"): diff --git a/sentient/tools/builtin/skills_tool.py b/sentient/tools/builtin/skills_tool.py index 5f0b02a4..772f5975 100644 --- a/sentient/tools/builtin/skills_tool.py +++ b/sentient/tools/builtin/skills_tool.py @@ -7,6 +7,8 @@ from sentient.skills.loader import slugify, valid_name from sentient.tools.base import Risk, ToolContext, ToolPlugin, tool +INCOGNITO_REFUSAL = "This is an incognito chat, so nothing from it is saved as a skill. Carry on without saving." + def _lib(ctx: ToolContext): return ctx.extra.get("skills") @@ -21,7 +23,7 @@ async def skill_view(ctx: ToolContext, name: str) -> dict: s = lib.get(name) if not s: return {"error": f"no skill named {name}", "available": [k.name for k in lib.list()][:30]} - if ctx.store is not None: + if ctx.store is not None and not await ctx.is_incognito(): # an incognito chat leaves no usage trail (#154) await lib.mark_used(s.name, ctx.store, viewed=True) out = {"name": s.name, "description": s.description, "version": s.version, "body": s.body} if s.browser_profile: # the skill's browser work happens in its own profile (docs/API.md section 12) @@ -46,6 +48,8 @@ async def skill_save( lib = _lib(ctx) if lib is None: return {"error": "skills unavailable"} + if await ctx.is_incognito(): + return {"error": INCOGNITO_REFUSAL} name = name.strip().lower() if not valid_name(name): name = slugify(name) diff --git a/tests/test_incognito.py b/tests/test_incognito.py new file mode 100644 index 00000000..9b17b94f --- /dev/null +++ b/tests/test_incognito.py @@ -0,0 +1,201 @@ +"""Incognito chats (#154): nothing is learned from them, nothing finds them, and they are deleted when closed.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi.testclient import TestClient + +from sentient.app import SentientApp +from sentient.gateway.app import create_app +from sentient.llm.events import Done, ToolResultEvent +from tests.conftest import FakeProvider, tool_call +from tests.memory.helpers import add_fact, drain + + +@pytest.fixture +async def app(config, isolated_home): + llm = FakeProvider() + a = SentientApp(config, llm=llm, db_path=isolated_home / "inc.db", enable_background=False) + await a.start() + a.fake = llm + yield a + await a.stop() + + +def _offered(call: dict) -> set[str]: + return {t["function"]["name"] for t in call["tools"] or []} + + +async def _turn(app, sid: str, text: str) -> list: + events = [ev async for ev in app.agent.run_turn(sid, text, channel="desktop")] + await app.agent.drain(timeout=5) + return events + + +async def test_an_incognito_turn_learns_nothing(app, monkeypatch): + app.config.memory.extract_after_turn = True + extracted: list[str] = [] + + async def fake_extract(text, user_name, **kw): + extracted.append(text) + return [] + + monkeypatch.setattr(app.memory, "extract_and_store", fake_extract) + sid = await app.store.create_session(channel="desktop", incognito=True) + async with app.bus.subscribe() as q: + app.fake.replies.append("Got it, good luck with the interview.") + events = await _turn(app, sid, "I have a job interview at Northwind next Tuesday morning") + seen = [e["type"] for e in drain(q)] + + assert any(isinstance(e, Done) for e in events) + assert extracted == [] # no fact extraction + assert "chat.turn_completed" not in seen # so skills, the user model and dreaming never hear of it + call = app.fake.calls[-1] + assert "memory_remember" not in _offered(call) and "skill_save" not in _offered(call) + assert "memory_recall" in _offered(call) # reading what Sentient knows stays on by default + assert "This is an incognito chat" in call["messages"][0]["content"] + + # the same words in a normal chat are extracted and announced: the exclusion is the flag, not the text + normal = await app.store.create_session(channel="desktop") + async with app.bus.subscribe() as q: + await _turn(app, normal, "I have a job interview at Northwind next Tuesday morning") + assert "chat.turn_completed" in [e["type"] for e in drain(q)] + assert extracted == ["I have a job interview at Northwind next Tuesday morning"] + assert "memory_remember" in _offered(app.fake.calls[-1]) + + +async def test_memory_and_skill_writes_refuse_in_an_incognito_chat(app): + sid = await app.store.create_session(channel="desktop", incognito=True) + ctx = app.agent.tool_context(sid, "subagent") # work the chat starts carries only its id: still refused + remember = app.registry.get("memory_remember") + out = await remember.call(ctx, {"fact": "Maya has an interview at Northwind"}) + assert "incognito" in out["error"] + rows = await app.store.fetchall("SELECT id FROM facts") + assert rows == [] # nothing stored, not even held for review + save = app.registry.get("skill_save") + out = await save.call(ctx, {"name": "interview-prep", "description": "d", "body": "b"}) + assert "incognito" in out["error"] and app.skills.get("interview-prep") is None + # a normal chat still saves + normal = app.agent.tool_context(await app.store.create_session(), "desktop") + assert (await remember.call(normal, {"fact": "Maya likes green tea"})).get("error") is None + + +async def test_a_model_call_to_memory_remember_is_refused_in_the_turn(app): + """A model that calls the tool anyway (it wasn't offered) gets an error and nothing is stored.""" + sid = await app.store.create_session(channel="desktop", incognito=True) + app.fake.replies.extend([[tool_call("memory_remember", fact="Maya has an interview")], "Okay."]) + events = await _turn(app, sid, "I have an interview tomorrow at nine") + [res] = [e for e in events if isinstance(e, ToolResultEvent)] + assert res.is_error and "error" in json.dumps(res.result) + assert await app.store.fetchall("SELECT id FROM facts") == [] + + +async def test_incognito_without_memory_reads_nothing(app): + app.config.chat.incognito_uses_memory = False + app.config.memory.min_similarity = 0.0 + await add_fact(app.memory, "Maya lives in Pune", source="manual") + sid = await app.store.create_session(channel="desktop", incognito=True) + app.fake.replies.append("I don't know.") + events = await _turn(app, sid, "where do I live") + call = app.fake.calls[-1] + system = call["messages"][0]["content"] + assert "Maya lives in Pune" not in system and "answer from this chat alone" in system + assert not {"memory_recall", "history_time_search", "user_model_ask"} & _offered(call) + [done] = [e for e in events if isinstance(e, Done)] + assert done.memory_sources == [] + out = await app.registry.get("memory_recall").call(app.agent.tool_context(sid, "desktop"), {"query": "Pune"}) + assert out == [] + + +async def test_incognito_chats_are_never_searched_summarized_or_reviewed(app): + inc = await app.store.create_session(channel="desktop", incognito=True) + normal = await app.store.create_session(channel="desktop") + for sid in (inc, normal): + for i in range(4): + await app.store.add_message(sid, "user" if i % 2 == 0 else "assistant", f"the purple umbrella {i}") + await app.store.add_message(sid, "tool", "{}", tool_call_id=f"c{i}", name="memory_recall") + old = (datetime.now(UTC) - timedelta(days=1)).isoformat() + await app.store.execute("UPDATE messages SET created_at = ?", (old,)) + await app.store.execute("UPDATE sessions SET updated_at = ?", (old,)) + + hits = await app.store.search_messages('"purple"', limit=50) + assert hits and {h["session_id"] for h in hits} == {normal} + chunks = await app.memory.episodic.pending_chunks() + assert chunks and {sid for sid, _ in chunks} == {normal} + rows = await app.memory.episodic.messages_between("2000-01-01", "2100-01-01") + assert rows and {r["session_id"] for r in rows} == {normal} + _evidence, sections, _outside = await app.user_model._gather("") + assert sections["messages"].count("purple umbrella") == 2 # the normal chat's two user messages only + app.config.evolution.min_tool_calls_for_review = 1 + due = await app.evolution.sessions_due(datetime.now(UTC), idle_minutes=0) + assert normal in due and inc not in due + assert (await app.evolution.review_session(inc, force=True))["decision"] == "skipped" + + +async def test_sweeper_deletes_closed_and_idle_incognito_chats(app): + keep = await app.store.create_session(channel="desktop") + fresh = await app.store.create_session(channel="desktop", incognito=True) + idle = await app.store.create_session(channel="desktop", incognito=True) + await app.store.add_message(idle, "user", "a secret") + old = (datetime.now(UTC) - timedelta(minutes=app.config.chat.incognito_delete_after_minutes + 1)).isoformat() + await app.store.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (old, idle)) + async with app.bus.subscribe() as q: + assert await app.incognito.sweep() == [idle] + assert [e["data"] for e in drain(q) if e["type"] == "session.deleted"] == [ + {"session_id": idle, "reason": "incognito"} + ] + assert await app.store.get_session(idle) is None + assert await app.store.fetchall("SELECT id FROM messages WHERE session_id = ?", (idle,)) == [] + # shutting down (or the next start) deletes every incognito chat; normal chats stay + assert await app.incognito.purge() == [fresh] + assert await app.store.get_session(keep) is not None + + +async def test_leftover_incognito_chats_are_deleted_at_start(config, isolated_home): + db = isolated_home / "left.db" + first = SentientApp(config, llm=FakeProvider(), db_path=db, enable_background=False) + await first.start() + sid = await first.store.create_session(channel="desktop", incognito=True) + await first.store.close() # the engine went away without a clean stop + again = SentientApp(config, llm=FakeProvider(), db_path=db, enable_background=False) + await again.start() + try: + assert await again.store.get_session(sid) is None + finally: + await again.stop() + + +@pytest.fixture +def client(config, isolated_home, monkeypatch): + monkeypatch.setenv("SENTIENT_GATEWAY_TOKEN", "test-token") + llm = FakeProvider(replies=["hello", "hi again"]) + app = create_app(SentientApp(config, llm=llm, db_path=isolated_home / "g.db", enable_background=False)) + with TestClient(app) as c: + c.headers.update({"Authorization": "Bearer test-token"}) + yield c + + +def test_gateway_creates_lists_and_deletes_incognito_chats(client): + with client.websocket_connect("/ws?token=test-token") as ws: + assert ws.receive_json()["type"] == "hello" + ws.send_json({"type": "chat.send", "text": "keep this between us", "incognito": True, "client_id": "c1"}) + sid = None + while True: + msg = ws.receive_json() + if msg["type"] == "session": + sid = msg["session_id"] + if msg["type"] == "done": + break + listed = {s["id"]: s for s in client.get("/api/sessions").json()} + assert listed[sid]["incognito"] is True + assert client.get("/api/sessions/search", params={"q": "between us"}).json() == [] + rest = client.post("/api/sessions", params={"incognito": "true"}).json()["session_id"] + normal = client.post("/api/sessions").json()["session_id"] + listed = {s["id"]: s for s in client.get("/api/sessions").json()} + assert listed[rest]["incognito"] is True and listed[normal]["incognito"] is False + assert client.delete(f"/api/sessions/{sid}").json() == {"ok": True} + assert sid not in {s["id"] for s in client.get("/api/sessions").json()} + assert client.get(f"/api/sessions/{sid}/messages").status_code == 404 # the window shows it no longer exists From d23965ba0cd3ce7f00655db0085ff54f89e65a0d Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sun, 11 Oct 2026 11:52:39 +0530 Subject: [PATCH 2/3] fix(chat): close incognito chats reliably and keep helpers from saving after deletion - the sweep waits until a reply's last write; the shutdown purge runs after replies and background work - deleting a chat cancels its helpers, and helpers keep the incognito flag - no-memory incognito prompts leave out the configured name and location - the window closes an incognito chat named after the user left, and keeps it marked until the engine confirms --- desktop/src/App.tsx | 18 ++++++++++++-- desktop/src/stores/chat.ts | 29 ++++++++++++++++------ docs/API.md | 7 ++++-- sentient/agent/incognito.py | 14 ++++------- sentient/agent/loop.py | 21 ++++++++++++++-- sentient/agent/subagents.py | 9 +++++++ sentient/app.py | 13 ++++++++++ sentient/gateway/routes/core.py | 4 +-- tests/test_incognito.py | 44 +++++++++++++++++++++++++++++++++ 9 files changed, 133 insertions(+), 26 deletions(-) diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 9c00b66f..f7b2d139 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -182,13 +182,27 @@ function BootstrapGate() { /** Leaving an incognito chat closes it: it is deleted (#154). The engine deletes leftovers when Sentient closes. */ function IncognitoCleanup() { const location = useLocation() + const known = useChat((s) => s.incognito) const current = location.pathname.match(/^\/chat\/([^/?#]+)/)?.[1] ?? null + // the new incognito chat view, which moves to its chat as soon as the engine names it + const state = location.state as { incognito?: boolean } | null + const onNewIncognito = location.pathname === '/chat' && (!!state?.incognito || /(^|[?&])incognito=1\b/.test(location.search)) const previous = useRef(null) + const visited = useRef(new Set()) useEffect(() => { const prev = previous.current previous.current = current - if (prev && prev !== current && isIncognito(prev)) useChat.getState().closeIncognito(prev) - }, [current]) + if (current) visited.current.add(current) + const leaving = new Set(Object.keys(known)) + if (prev && isIncognito(prev)) leaving.add(prev) + for (const id of leaving) { + if (id === current) continue + // named while its view was still open: that view is about to show it. Named after the user left: close it. + if (!visited.current.has(id) && onNewIncognito) continue + visited.current.delete(id) + useChat.getState().closeIncognito(id) + } + }, [current, known, onNewIncognito]) return null } diff --git a/desktop/src/stores/chat.ts b/desktop/src/stores/chat.ts index ed6257d5..619f282d 100644 --- a/desktop/src/stores/chat.ts +++ b/desktop/src/stores/chat.ts @@ -9,7 +9,7 @@ */ import { toast } from 'sonner' import { create } from 'zustand' -import { api, errorMessage } from '@/lib/api' +import { api, errorMessage, isApiError } from '@/lib/api' import { applyAgentEvent, emptyTurn, meterFrom, type AssistantTurnView, type AttachmentView, type TimelineItem, type UserMessageView } from '@/lib/chatFold' import { queryClient } from '@/lib/queryClient' import type { AgentEvent, ApprovalDecision, ChatEvent, ContextMeter, Session, TranscriptMessage } from '@/lib/types' @@ -64,6 +64,8 @@ interface ChatState { } const pendingKey = (clientId: string) => `pending:${clientId}` +/** Incognito chats being deleted right now (#154). */ +const closing = new Set() function patchLive(state: ChatState, key: string, fn: (l: LiveSession) => LiveSession): Partial { const cur = state.live[key] @@ -333,17 +335,28 @@ export const useChat = create((set, get) => ({ }, closeIncognito: (sessionId) => { + if (closing.has(sessionId)) return + closing.add(sessionId) get().cancel(sessionId) get().dropLive(sessionId) - const { [sessionId]: _gone, ...incognito } = get().incognito - void _gone - const { [sessionId]: _meter, ...context } = get().context - void _meter - set({ incognito, context }) queryClient.setQueryData(qk.sessions, (old) => old?.filter((s) => s.id !== sessionId)) queryClient.removeQueries({ queryKey: qk.messages(sessionId) }) - // the engine also deletes it at shutdown, at the next start and after a while without messages - void api.sessions.delete(sessionId).catch(() => undefined) + const forget = () => { + const { [sessionId]: _gone, ...incognito } = get().incognito + void _gone + const { [sessionId]: _meter, ...context } = get().context + void _meter + set({ incognito, context }) + } + // the marker stays until the engine confirms, so leaving another page tries again; the engine also deletes it at + // shutdown, at the next start and after a while without messages + api.sessions + .delete(sessionId) + .then(forget, (err) => { + if (isApiError(err) && err.status === 404) forget() + else toast.error("Couldn't delete the incognito chat yet", { description: 'Sentient will delete it when it closes.' }) + }) + .finally(() => closing.delete(sessionId)) } })) diff --git a/docs/API.md b/docs/API.md index b1afbaff..a7e4e30f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -200,7 +200,7 @@ accept or loosen a rule. incognito chats out of its chat list) - `POST /api/sessions?incognito=false` → `{session_id}` - `PATCH /api/sessions/{id}` `{title}` → `{ok}` -- `DELETE /api/sessions/{id}` → `{ok}` +- `DELETE /api/sessions/{id}` → `{ok}` (helpers the chat started are cancelled first; publishes `session.deleted`) - `GET /api/sessions/{id}/messages?limit=500` → raw transcript rows, oldest first (404 when the chat doesn't exist, e.g. a closed incognito chat): ```json @@ -236,7 +236,10 @@ accept or loosen a rule. memory file, daily notes, facts or insights, and the memory look-up tools are not offered and return nothing. The prompt also tells the model the chat is incognito. - it is deleted: the window deletes it when the user leaves it; the engine deletes every incognito chat at start - and shutdown, and one with no new message for `chat.incognito_delete_after_minutes` (default 30) within a minute. + and shutdown (after replies and background work have finished), and one with no new message for + `chat.incognito_delete_after_minutes` (default 30) within a minute; a reply still writing is never cut off. + Helpers it started keep refusing to save even if it is deleted first. Without memory, its prompt also leaves out + the configured name and location. Each engine deletion publishes `session.deleted` with `reason: "incognito"`. - `POST /api/approvals` `{approval_id, decision}` → `{resolved}` diff --git a/sentient/agent/incognito.py b/sentient/agent/incognito.py index 0802f9dc..79e7af6a 100644 --- a/sentient/agent/incognito.py +++ b/sentient/agent/incognito.py @@ -8,7 +8,6 @@ from __future__ import annotations -import contextlib import logging from datetime import UTC, datetime, timedelta @@ -28,9 +27,7 @@ async def start(self) -> None: self.run_every(SWEEP_EVERY_S, self.sweep, name="sweep", initial_delay=SWEEP_EVERY_S) async def stop(self) -> None: - await super().stop() - with contextlib.suppress(Exception): - await self.purge(skip_running=False) + await super().stop() # the final purge runs in SentientApp.stop, after replies and background work end async def sweep(self, now: datetime | None = None) -> list[str]: """Delete incognito chats with no new message for ``chat.incognito_delete_after_minutes``.""" @@ -39,15 +36,14 @@ async def sweep(self, now: datetime | None = None) -> list[str]: return await self.purge(cutoff.isoformat()) async def purge(self, idle_before: str | None = None, *, skip_running: bool = True) -> list[str]: - """Delete incognito chats (only those idle since ``idle_before`` when given). A chat whose reply is still - running is left for the next sweep unless ``skip_running`` is off. Returns the deleted ids.""" + """Delete incognito chats (only those idle since ``idle_before`` when given). A chat with a reply that hasn't + finished writing is left for the next sweep unless ``skip_running`` is off. Returns the deleted ids.""" agent = self.app.agent deleted: list[str] = [] for sid in await self.app.store.incognito_sessions(idle_before): - if skip_running and agent is not None and agent.is_running(sid): + if skip_running and agent is not None and agent.is_busy(sid): continue - await self.app.store.delete_session(sid) - self.app.bus.publish("session.deleted", {"session_id": sid, "reason": "incognito"}) + await self.app.delete_chat(sid, reason="incognito") deleted.append(sid) if deleted: log.info("deleted %d incognito chat(s)", len(deleted)) diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py index 8b7c59c5..b03039d5 100644 --- a/sentient/agent/loop.py +++ b/sentient/agent/loop.py @@ -435,6 +435,8 @@ def __init__( self._background: set[asyncio.Task] = set() self._steers: dict[str, SteerQueue] = {} self._turn_tasks: weakref.WeakSet[asyncio.Task] = weakref.WeakSet() # tasks running a chat reply + # session id -> chat turns not finished yet, counted until their last write (incognito cleanup waits, #154) + self._busy: dict[str, int] = {} # ------------------------------------------------------------------ steering def is_running(self, session_id: str) -> bool: @@ -442,6 +444,17 @@ def is_running(self, session_id: str) -> bool: q = self._steers.get(session_id) return bool(q and q.accepting) + def is_busy(self, session_id: str) -> bool: + """True from the start of a chat turn until its last write (the final or stopped reply) is done.""" + return self._busy.get(session_id, 0) > 0 + + def _turn_done(self, session_id: str) -> None: + left = self._busy.get(session_id, 0) - 1 + if left > 0: + self._busy[session_id] = left + else: + self._busy.pop(session_id, None) + def steer(self, session_id: str, text: str) -> bool: """Queue ``text`` for the reply running in ``session_id``. Returns False when no reply is running (the caller should start a normal turn instead).""" @@ -524,9 +537,9 @@ async def _system_prompt_with_sources( facts=facts, skills_index=self.skills.prompt_index(), assistant_name=self.config.assistant.name, - user_name=self.config.assistant.user_name, + user_name=self.config.assistant.user_name if reads_memory else "", channel=channel, - location=self.config.assistant.location, + location=self.config.assistant.location if reads_memory else "", user_context=user_context, tool_names=[t.name for t in self.registry.tools()], read_only_apps=self._read_only_apps(), @@ -1121,6 +1134,7 @@ async def run_turn( turn_id = new_id() ev = {"session_id": session_id, "turn_id": turn_id} attachments = attachments or [] + self._busy[session_id] = self._busy.get(session_id, 0) + 1 # register for steering; a second concurrent turn on the same session (rare) does not take it over steer: SteerQueue | None = None if not self.is_running(session_id): @@ -1250,9 +1264,11 @@ async def persist(role_: str, content: str | None, **fields: Any) -> None: await asyncio.shield(save) if on_stopped is not None and not save.cancelled(): on_stopped(save.result()) + self._turn_done(session_id) raise except BaseException: release() + self._turn_done(session_id) raise leftover = release() @@ -1289,6 +1305,7 @@ async def persist(role_: str, content: str | None, **fields: Any) -> None: }, ) + self._turn_done(session_id) if leftover: # steer messages that arrived after the final round start a new turn async for event in self.run_turn(session_id, "\n\n".join(leftover), channel=channel, model=model): diff --git a/sentient/agent/subagents.py b/sentient/agent/subagents.py index 4acbbbb1..8aa424fe 100644 --- a/sentient/agent/subagents.py +++ b/sentient/agent/subagents.py @@ -155,6 +155,14 @@ def is_running(self, subagent_id: str) -> bool: return bool(t and not t.done()) # ------------------------------------------------------------------ actions + async def cancel_for_session(self, session_id: str) -> int: + """Cancel the helpers a chat started (before the chat is deleted). Returns how many were running.""" + rows = await self.app.store.fetchall( + "SELECT id FROM subagents WHERE session_id = ? AND status = 'running'", (session_id,) + ) + tasks = [t for r in rows if (t := self._tasks.get(r["id"])) is not None and not t.done()] + return await cancel_tasks(tasks, timeout=10) if tasks else 0 + async def cancel(self, subagent_id: str) -> dict | None: task = self._tasks.get(subagent_id) if task is not None and not task.done(): @@ -371,6 +379,7 @@ def note(message: dict, text: str | None = None) -> None: {"role": "user", "content": f"Goal: {goal}" + (f"\n\nContext:\n{context}" if context else "")}, ] ctx = agent.tool_context(session_id, "subagent", origin=origin) + ctx.incognito = await self.app.store.is_incognito(session_id) # kept if the chat is deleted (#154) ctx.untrusted = untrusted ctx.extra["subagent_id"] = sub_id names = await self._tool_names(goal, context, tools, cfg.role) diff --git a/sentient/app.py b/sentient/app.py index a8948327..37baba72 100644 --- a/sentient/app.py +++ b/sentient/app.py @@ -212,6 +212,15 @@ async def resume(self, source: str = "desktop") -> dict[str, Any]: log.exception("resuming interrupted tasks failed") return dict(self.stop_state) + async def delete_chat(self, session_id: str, *, reason: str | None = None) -> None: + """Delete a chat: its helpers are cancelled first, so none keeps working for a chat that is gone.""" + try: + await self.subagents.cancel_for_session(session_id) + except Exception: + log.exception("cancelling the helpers of chat %s failed", session_id) + await self.store.delete_session(session_id) + self.bus.publish("session.deleted", {"session_id": session_id, **({"reason": reason} if reason else {})}) + def tool_extra(self) -> dict[str, Any]: return {"app": self, "skills": self.skills, "workspace": self.workspace, "registry": self.registry} @@ -275,6 +284,10 @@ async def stop(self) -> None: log.exception("service %s failed to stop", svc.name) if self.agent is not None: await self.agent.drain(timeout=SHUTDOWN_GRACE_S) + try: # incognito chats are deleted when Sentient closes (#154), once nothing writes to them any more + await self.incognito.purge(skip_running=False) + except Exception: + log.exception("deleting incognito chats at shutdown failed") claude_code.kill_all() await self.store.close() self._started = False diff --git a/sentient/gateway/routes/core.py b/sentient/gateway/routes/core.py index fd04c871..c532c201 100644 --- a/sentient/gateway/routes/core.py +++ b/sentient/gateway/routes/core.py @@ -285,9 +285,7 @@ async def rename_session(request: Request, session_id: str, body: RenameBody): @router.delete("/api/sessions/{session_id}", dependencies=_auth) async def delete_session(request: Request, session_id: str): - s = get_core(request) - await s.store.delete_session(session_id) - s.bus.publish("session.deleted", {"session_id": session_id}) + await get_core(request).delete_chat(session_id) return {"ok": True} diff --git a/tests/test_incognito.py b/tests/test_incognito.py index 9b17b94f..1c41f68e 100644 --- a/tests/test_incognito.py +++ b/tests/test_incognito.py @@ -199,3 +199,47 @@ def test_gateway_creates_lists_and_deletes_incognito_chats(client): assert client.delete(f"/api/sessions/{sid}").json() == {"ok": True} assert sid not in {s["id"] for s in client.get("/api/sessions").json()} assert client.get(f"/api/sessions/{sid}/messages").status_code == 404 # the window shows it no longer exists + + +async def test_without_memory_the_prompt_has_no_personal_details(app): + app.config.chat.incognito_uses_memory = False + app.config.assistant.location = "Koramangala, Bengaluru" + sid = await app.store.create_session(channel="desktop", incognito=True) + app.fake.replies.append("Hello.") + await _turn(app, sid, "hi there") + system = app.fake.calls[-1]["messages"][0]["content"] + # the configured name and place stay out (the persona the user wrote is kept as it is) + assert "Koramangala" not in system and "- User: Sarthak" not in system + + +async def test_a_reply_still_writing_is_not_swept_and_deleting_cancels_helpers(app, monkeypatch): + sid = await app.store.create_session(channel="desktop", incognito=True) + app.agent._busy[sid] = 1 # a turn between its last model call and its final write + assert await app.incognito.purge() == [] + app.agent._turn_done(sid) + assert not app.agent.is_busy(sid) + cancelled: list[str] = [] + + async def fake_cancel(session_id): + cancelled.append(session_id) + return 0 + + monkeypatch.setattr(app.subagents, "cancel_for_session", fake_cancel) + assert await app.incognito.purge() == [sid] + assert cancelled == [sid] and await app.store.get_session(sid) is None + + +async def test_turn_busy_count_covers_the_final_write(app): + sid = await app.store.create_session(channel="desktop", incognito=True) + seen: list[bool] = [] + real_add = app.store.add_message + + async def spy(session_id, role, content, **kw): + if role == "assistant": + seen.append(app.agent.is_busy(session_id)) + return await real_add(session_id, role, content, **kw) + + app.store.add_message = spy + app.fake.replies.append("Done.") + await _turn(app, sid, "hello") + assert seen == [True] and not app.agent.is_busy(sid) From 543c6f2e2a6b9e5013bd5f860a9667cb96e857d6 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sun, 11 Oct 2026 12:13:29 +0530 Subject: [PATCH 3/3] fix(chat): stop a deleted chat's replies first and keep the busy count exact - run_turn ends its busy count in a finally that also covers a stream closed at Done - delete_chat cancels the chat's running replies (they save what they kept) before helpers and the row - helpers of an incognito chat without memory don't get the user's name --- sentient/agent/loop.py | 86 +++++++++++++++++++++++-------------- sentient/agent/subagents.py | 11 +++-- sentient/app.py | 7 ++- tests/test_incognito.py | 45 +++++++++++++++++++ 4 files changed, 111 insertions(+), 38 deletions(-) diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py index b03039d5..25a9a092 100644 --- a/sentient/agent/loop.py +++ b/sentient/agent/loop.py @@ -437,6 +437,7 @@ def __init__( self._turn_tasks: weakref.WeakSet[asyncio.Task] = weakref.WeakSet() # tasks running a chat reply # session id -> chat turns not finished yet, counted until their last write (incognito cleanup waits, #154) self._busy: dict[str, int] = {} + self._session_tasks: dict[str, set[asyncio.Task]] = {} # session id -> tasks running its chat turns # ------------------------------------------------------------------ steering def is_running(self, session_id: str) -> bool: @@ -454,6 +455,23 @@ def _turn_done(self, session_id: str) -> None: self._busy[session_id] = left else: self._busy.pop(session_id, None) + task = asyncio.current_task() + tasks = self._session_tasks.get(session_id) + if tasks is not None and task is not None: + tasks.discard(task) + if not tasks: + self._session_tasks.pop(session_id, None) + + async def cancel_session(self, session_id: str, timeout: float = 10.0) -> int: + """Stop the chat turns running for ``session_id`` and wait until they have saved what they keep (a chat + about to be deleted). Returns how many were stopped.""" + me = asyncio.current_task() + tasks = [t for t in self._session_tasks.get(session_id, set()) if not t.done() and t is not me] + for t in tasks: + t.cancel() + if tasks: + await asyncio.wait(tasks, timeout=timeout) + return len(tasks) def steer(self, session_id: str, text: str) -> bool: """Queue ``text`` for the reply running in ``session_id``. Returns False when no reply is running @@ -1135,6 +1153,8 @@ async def run_turn( ev = {"session_id": session_id, "turn_id": turn_id} attachments = attachments or [] self._busy[session_id] = self._busy.get(session_id, 0) + 1 + if (current := asyncio.current_task()) is not None: + self._session_tasks.setdefault(session_id, set()).add(current) # register for steering; a second concurrent turn on the same session (rare) does not take it over steer: SteerQueue | None = None if not self.is_running(session_id): @@ -1272,40 +1292,42 @@ async def persist(role_: str, content: str | None, **fields: Any) -> None: raise leftover = release() - if not (result.error and not result.text): - memory_sources = await sources.resolve(self.store) - message_id = await self.store.add_message( - session_id, "assistant", result.text, thinking=result.thinking or None, memory_sources=memory_sources - ) - yield Done(content=result.text, message_id=message_id, memory_sources=memory_sources, **ev) - - said = "\n\n".join([user_text, *result.interjections]).strip() - # nothing is learned from an incognito chat (#154): no fact extraction, no memory flush, and no - # chat.turn_completed, so skills, the user model and dreaming never see it - if self.memory is not None and self.config.memory.extract_after_turn and not incognito: - # a chat that read outside content holds what it learns for the user's review (ADR 0021) - log.info("chat %s: fact extraction started for this turn", session_id) - self._spawn(self._extract(said, memory_review.for_context(ctx)), "memory") - if self.config.chat.auto_title and session and (session.get("title") or "") == (user_text or "")[:60]: - self._spawn(self._auto_title(session_id, user_text, result.text), "titles") - self._spawn(self._maybe_compress(session_id, flush=not incognito), "memory") - if incognito: - log.info("incognito chat %s: nothing learned from this turn", session_id) - elif self.app is not None: - self.app.bus.publish( - "chat.turn_completed", - { - "session_id": session_id, - "turn_id": turn_id, - "tool_calls": result.tool_calls, - "tool_errors": result.tool_errors, - "skills_viewed": result.skills_viewed, - "user_text": said, - "reply": result.text, - }, + try: # the turn counts as busy until here, also when the caller stops reading at Done + if not (result.error and not result.text): + memory_sources = await sources.resolve(self.store) + message_id = await self.store.add_message( + session_id, "assistant", result.text, thinking=result.thinking or None, memory_sources=memory_sources ) + yield Done(content=result.text, message_id=message_id, memory_sources=memory_sources, **ev) + + said = "\n\n".join([user_text, *result.interjections]).strip() + # nothing is learned from an incognito chat (#154): no fact extraction, no memory flush, and no + # chat.turn_completed, so skills, the user model and dreaming never see it + if self.memory is not None and self.config.memory.extract_after_turn and not incognito: + # a chat that read outside content holds what it learns for the user's review (ADR 0021) + log.info("chat %s: fact extraction started for this turn", session_id) + self._spawn(self._extract(said, memory_review.for_context(ctx)), "memory") + if self.config.chat.auto_title and session and (session.get("title") or "") == (user_text or "")[:60]: + self._spawn(self._auto_title(session_id, user_text, result.text), "titles") + self._spawn(self._maybe_compress(session_id, flush=not incognito), "memory") + if incognito: + log.info("incognito chat %s: nothing learned from this turn", session_id) + elif self.app is not None: + self.app.bus.publish( + "chat.turn_completed", + { + "session_id": session_id, + "turn_id": turn_id, + "tool_calls": result.tool_calls, + "tool_errors": result.tool_errors, + "skills_viewed": result.skills_viewed, + "user_text": said, + "reply": result.text, + }, + ) - self._turn_done(session_id) + finally: + self._turn_done(session_id) if leftover: # steer messages that arrived after the final round start a new turn async for event in self.run_turn(session_id, "\n\n".join(leftover), channel=channel, model=model): diff --git a/sentient/agent/subagents.py b/sentient/agent/subagents.py index 8aa424fe..ba94571d 100644 --- a/sentient/agent/subagents.py +++ b/sentient/agent/subagents.py @@ -331,14 +331,15 @@ async def _tool_names(self, goal: str, context: str, tools: list[str] | None, ro return [t.name for t in usable] return [n for n in selected if n in usable_names] - def _system_prompt(self) -> str: + def _system_prompt(self, *, private: bool = False) -> str: + """``private``: started from an incognito chat that doesn't use memory (#154), so the user's name stays out.""" from sentient.tools.builtin.time_tool import resolve_tz cfg = self.app.config.assistant now = datetime.now(resolve_tz(cfg.timezone)) return SUBAGENT_SYSTEM.format( assistant=cfg.name or "Sentient", - user=cfg.user_name or "the user", + user=(None if private else cfg.user_name) or "the user", now=f"{now.strftime('%A %Y-%m-%d %H:%M')} ({now.tzinfo})", ) @@ -374,12 +375,14 @@ def note(message: dict, text: str | None = None) -> None: async with self._slot(): before = await asyncio.to_thread(_snapshot_files) note({"type": "info", "content": f"Started: {goal[:200]}"}, "Started") + incognito = await self.app.store.is_incognito(session_id) # kept if the chat is deleted (#154) + private = incognito and not self.app.config.chat.incognito_uses_memory messages = [ - {"role": "system", "content": self._system_prompt()}, + {"role": "system", "content": self._system_prompt(private=private)}, {"role": "user", "content": f"Goal: {goal}" + (f"\n\nContext:\n{context}" if context else "")}, ] ctx = agent.tool_context(session_id, "subagent", origin=origin) - ctx.incognito = await self.app.store.is_incognito(session_id) # kept if the chat is deleted (#154) + ctx.incognito = incognito ctx.untrusted = untrusted ctx.extra["subagent_id"] = sub_id names = await self._tool_names(goal, context, tools, cfg.role) diff --git a/sentient/app.py b/sentient/app.py index 37baba72..2af8f2b6 100644 --- a/sentient/app.py +++ b/sentient/app.py @@ -213,11 +213,14 @@ async def resume(self, source: str = "desktop") -> dict[str, Any]: return dict(self.stop_state) async def delete_chat(self, session_id: str, *, reason: str | None = None) -> None: - """Delete a chat: its helpers are cancelled first, so none keeps working for a chat that is gone.""" + """Delete a chat: its running replies and helpers are stopped first, so none keeps writing to or working for a + chat that is gone (a stopped reply saves what it kept, then goes with the chat).""" try: + if self.agent is not None: + await self.agent.cancel_session(session_id) await self.subagents.cancel_for_session(session_id) except Exception: - log.exception("cancelling the helpers of chat %s failed", session_id) + log.exception("stopping the work of chat %s failed", session_id) await self.store.delete_session(session_id) self.bus.publish("session.deleted", {"session_id": session_id, **({"reason": reason} if reason else {})}) diff --git a/tests/test_incognito.py b/tests/test_incognito.py index 1c41f68e..78a13ccc 100644 --- a/tests/test_incognito.py +++ b/tests/test_incognito.py @@ -243,3 +243,48 @@ async def spy(session_id, role, content, **kw): app.fake.replies.append("Done.") await _turn(app, sid, "hello") assert seen == [True] and not app.agent.is_busy(sid) + + +async def test_closing_the_stream_at_done_still_ends_the_busy_count(app): + sid = await app.store.create_session(channel="desktop", incognito=True) + app.fake.replies.append("Hi.") + gen = app.agent.run_turn(sid, "hello", channel="desktop") + async for ev in gen: + if isinstance(ev, Done): + break + await gen.aclose() # the window went away right at the final event + assert not app.agent.is_busy(sid) + assert await app.incognito.purge() == [sid] + + +async def test_deleting_a_chat_stops_its_running_reply_first(app): + import asyncio + + sid = await app.store.create_session(channel="desktop", incognito=True) + started, release = asyncio.Event(), asyncio.Event() + real_stream = app.fake.stream + + async def slow_stream(*a, **k): + started.set() + await release.wait() + async for chunk in real_stream(*a, **k): + yield chunk + + app.fake.stream = slow_stream + + async def consume(): + return [ev async for ev in app.agent.run_turn(sid, "tell me something", channel="desktop")] + + turn = asyncio.create_task(consume()) + await asyncio.wait_for(started.wait(), 5) + await app.delete_chat(sid, reason="incognito") + assert turn.done() and turn.cancelled() + assert await app.store.get_session(sid) is None + assert await app.store.fetchall("SELECT id FROM messages WHERE session_id = ?", (sid,)) == [] + assert not app.agent.is_busy(sid) + + +async def test_helpers_of_a_private_incognito_chat_do_not_get_the_name(app): + app.config.chat.incognito_uses_memory = False + assert "Sarthak" in app.subagents._system_prompt() + assert "Sarthak" not in app.subagents._system_prompt(private=True)