Skip to content
Merged
6 changes: 6 additions & 0 deletions changes/152.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**Every memory shows where it came from, how sure Sentient is, and whether it still holds.** Open a memory to see each
place that told Sentient, with the words it said, and jump straight to that chat message, email or note. Sentient gets
surer each time something says it again, and you can mark a memory as not right so it stays out of chats and tasks.
When a newer memory replaces an old one, the old one is kept as history instead of disappearing. Deleting a memory now
also removes what was built from it: chat summaries, the nightly journal's notes, insights about you and the lines in
USER.md and MEMORY.md. Memories learned in Telegram, Discord, WhatsApp or on a device before this update now say so.
26 changes: 24 additions & 2 deletions desktop/src/features/chat/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,27 @@ function ChatView({
return [...history, ...liveItems(liveSession)]
}, [messages.data, liveSession])

// ?message=<id> (a memory's source, #152): scroll to that message once it is on screen, and mark it a moment
const focusId = new URLSearchParams(useLocation().search).get("message")
const loadedAll = !messages.isFetching
const focused = useRef<string | null>(null) // done once per link, not again when the chat refetches
useEffect(() => {
if (!focusId || items.length === 0 || !loadedAll || focused.current === focusId) return
const t = window.setTimeout(() => {
focused.current = focusId
const el = document.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(focusId)}"]`)
if (!el) {
// this view shows a chat's newest 500 messages: say so rather than leave the user guessing
if (!(messages.data ?? []).some((m) => m.id === focusId)) toast.info('That message is further back in this chat than Sentient shows here.')
return
}
el.scrollIntoView({ block: 'center' })
el.classList.add('ring-2', 'ring-accent/60')
window.setTimeout(() => el.classList.remove('ring-2', 'ring-accent/60'), 2400)
}, 350)
return () => window.clearTimeout(t)
}, [focusId, items.length, loadedAll])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const streaming = !!liveSession?.streaming
const lastAssistantIndex = items.map((i) => i.kind).lastIndexOf('assistant')

Expand Down Expand Up @@ -397,8 +418,9 @@ function Timeline({ items, streaming, loading, children }: { items: TimelineItem
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
}, [])

// `?at=top` keeps the first message in view (deep links from search, screenshots).
const atTop = useRef(/(^|[?&])at=top\b/.test(useLocation().search))
// `?at=top` keeps the first message in view (deep links from search, screenshots); `?message=` (a memory's source,
// #152) doesn't jump to the bottom either, so the page can scroll to that message.
const atTop = useRef(/(^|[?&])(at=top\b|message=)/.test(useLocation().search))
if (atTop.current) stick.current = false

useLayoutEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/chat/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function AttachmentChip({ a, className }: { a: AttachmentView; className?
export const UserMessage = memo(function UserMessage({ message, onRestore }: { message: UserMessageView; onRestore?: (text: string) => void }) {
const [copied, setCopied] = useState(false)
return (
<div className={cn('group/user flex flex-col items-end gap-1.5', message.pending && 'opacity-80')}>
<div data-message-id={message.id} className={cn('group/user flex flex-col items-end gap-1.5 rounded-xl transition-shadow', message.pending && 'opacity-80')}>
{message.attachments.length > 0 && (
<div className="flex max-w-[85%] flex-wrap justify-end gap-1.5">
{message.attachments.map((a) => (
Expand Down
49 changes: 39 additions & 10 deletions desktop/src/features/memory/MemoryDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { IconCalendar, IconCheck, IconClock, IconHistory, IconHourglassHigh, IconPencil, IconTrash, IconX } from '@tabler/icons-react'
import { IconCalendar, IconCheck, IconClock, IconGauge, IconHistory, IconHourglassHigh, IconMoodConfuzed, IconPencil, IconReplace, IconRoute, IconTrash, IconX } from '@tabler/icons-react'
import { useEffect, useState } from 'react'
import { Alert, Button, ConfirmDialog, Select, Sheet, Textarea } from '@/components/ui'
import { useMemoryScopes } from '@/hooks/memory'
import { useHotkey } from '@/hooks/useHotkey'
import { formatDateTime } from '@/lib/utils'
import { ExpiryBadge, ScopeIcon, scopeLabel, SourceChip, TopicPill } from './bits'
import { ExpiryBadge, ScopeIcon, scopeLabel, TopicPill } from './bits'
import { useNow, useOptimisticMemoryActions } from './hooks'
import { countdown, expiresInMs, recentRelative, sourceMeta, type MemoryWithHistory } from './meta'
import { Confidence, SourceLinks } from './SourceLinks'

export function MemoryDetail({
memory,
Expand All @@ -19,7 +20,7 @@ export function MemoryDetail({
onOpenChange: (open: boolean) => void
onForgetSource: (source: string) => void
}) {
const { update, remove, move } = useOptimisticMemoryActions()
const { update, remove, move, setState } = useOptimisticMemoryActions()
const scopes = useMemoryScopes()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
Expand Down Expand Up @@ -91,6 +92,24 @@ export function MemoryDetail({
<ExpiryBadge memory={memory} now={now} />
</div>

{memory.state === 'disputed' && (
<Alert tone="warning" icon={<IconMoodConfuzed />} title="You said this isn't right">
Sentient keeps it here but leaves it out of chats and tasks. Edit it, forget it, or{' '}
<button type="button" className="font-medium underline underline-offset-2" onClick={() => setState.mutate({ id: memory.id, state: 'current' })}>
say it's right after all
</button>
.
</Alert>
)}
{memory.state === 'superseded' && (
<Alert tone="info" icon={<IconReplace />} title="Replaced by a newer memory">
{memory.superseded_by_content ? `Now: "${memory.superseded_by_content}". ` : ''}Sentient keeps this one as history and doesn't use it.{' '}
<button type="button" className="font-medium underline underline-offset-2" onClick={() => setState.mutate({ id: memory.id, state: 'current' })}>
It's still true
</button>
</Alert>
)}

{memory.previous_content && memory.previous_content !== memory.content && (
<div className="rounded-xl border border-border bg-sunken/60 p-3.5">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
Expand All @@ -100,15 +119,25 @@ export function MemoryDetail({
</div>
)}

<section aria-label="Where it came from" className="space-y-2">
<h3 className="flex items-center gap-1.5 text-xs font-medium text-fg-muted">
<IconRoute size={13} /> Where it came from
</h3>
<SourceLinks memory={memory} onForgetSource={onForgetSource} />
</section>

<dl className="divide-y divide-border rounded-xl border border-border bg-surface text-sm">
<Row icon={<src.icon size={14} />} label="Source">
<div className="flex items-center justify-between gap-2">
<SourceChip source={memory.source} className="text-sm text-fg" />
<button type="button" onClick={() => onForgetSource(memory.source)} className="text-xs text-fg-subtle underline-offset-2 hover:text-danger hover:underline">
Forget all from this source
<Row icon={<IconGauge size={14} />} label="How sure">
<Confidence memory={memory} />
{memory.state !== 'disputed' && memory.state !== 'superseded' && (
<button
type="button"
onClick={() => setState.mutate({ id: memory.id, state: 'disputed' })}
className="mt-1.5 text-xs text-fg-subtle underline-offset-2 hover:text-warning hover:underline"
>
This isn't right
</button>
</div>
<div className="text-xs text-fg-subtle">{src.description}</div>
)}
</Row>
<Row icon={<ScopeIcon scope={memory.scope} size={14} />} label="Used in">
<Select
Expand Down
16 changes: 16 additions & 0 deletions desktop/src/features/memory/MemoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ function MemoryCard({ memory, selected, onSelect, now, compact }: { memory: Memo
<TopicPill key={t} topic={t} size="xs" />
))}
<ExpiryBadge memory={memory} now={now} className="h-4.5" />
{memory.state === 'disputed' && (
<span className="rounded-full bg-warning/15 px-1.5 text-2xs font-medium text-warning" title="You said this isn't right: left out of chats and tasks">
Not right?
</span>
)}
{memory.state === 'superseded' && <span className="rounded-full bg-sunken px-1.5 text-2xs font-medium text-fg-subtle">Replaced</span>}
{memory.state !== 'disputed' && typeof memory.confidence === 'number' && memory.confidence < 0.7 && (
<span className="rounded-full bg-sunken px-1.5 text-2xs font-medium text-fg-subtle" title={`Sentient is ${Math.round(memory.confidence * 100)}% sure`}>
Not sure yet
</span>
)}
{(memory.sources?.length ?? 0) > 1 && (
<span className="rounded-full bg-sunken px-1.5 text-2xs font-medium text-fg-subtle" title={memory.sources!.map((s) => s.source).join(', ')}>
{memory.sources!.length} sources
</span>
)}
{typeof memory.similarity === 'number' && (
<span className="rounded-full bg-accent/12 px-1.5 text-2xs font-medium text-accent-text">{Math.round(memory.similarity * 100)}% match</span>
)}
Expand Down
106 changes: 106 additions & 0 deletions desktop/src/features/memory/SourceLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Where a memory came from, exactly (#152): every source with the words it said, each opening what it points at
* (the chat message, the email, the note). And how sure Sentient is of it.
*/
import { IconArrowUpRight, IconQuote } from '@tabler/icons-react'
import { useNavigate } from 'react-router'
import { getBridge } from '@/lib/bridge'
import type { Memory, MemorySourceLink, MemorySourceRef } from '@/lib/types'
import { cn, relativeTime } from '@/lib/utils'
import { SourceChip } from './bits'

/** What opening a source does, or null when there's nothing to open. */
function opener(ref: MemorySourceRef | null, navigate: (to: string) => void): { label: string; open: () => void } | null {
if (!ref) return null
if (ref.kind === 'message' && ref.session_id) {
const to = `/chat/${ref.session_id}${ref.message_id ? `?message=${encodeURIComponent(ref.message_id)}` : ''}`
return { label: 'Open the chat', open: () => navigate(to) }
}
if (ref.url && /^(https?|obsidian):\/\//.test(ref.url)) {
return { label: ref.kind === 'email' ? 'Open the email' : ref.kind === 'note' ? 'Open the note' : 'Open', open: () => void getBridge().openExternal(ref.url!) }
}
if (ref.session_id) return { label: 'Open the chat that read it', open: () => navigate(`/chat/${ref.session_id}`) }
return null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function where(link: MemorySourceLink): string | null {
const ref = link.ref
if (!ref) return null
if (ref.kind === 'email' && ref.title) return `Email: ${ref.title}`
if (ref.kind === 'note') return ref.title ? `Note: ${ref.title}` : ref.path ?? null
if (ref.kind === 'file' && ref.file) return ref.file
if (ref.kind === 'app' && ref.title) return ref.title
return null
}

export function SourceLinks({ memory, onForgetSource }: { memory: Memory; onForgetSource: (source: string) => void }) {
const navigate = useNavigate()
const links: MemorySourceLink[] =
memory.sources && memory.sources.length > 0 ? memory.sources : [{ source: memory.source, ref: memory.ref ?? null, at: memory.created_at, own: true }]
return (
<ul className="space-y-2.5">
{links.map((link) => {
const go = opener(link.ref, navigate)
const place = where(link)
return (
<li key={link.source} className="rounded-lg border border-border bg-sunken/40 px-3 py-2.5">
<div className="flex items-center gap-2">
<SourceChip source={link.source} className="text-sm text-fg" />
<span className="shrink-0 text-xs text-fg-subtle">· {relativeTime(link.at)}</span>
{go && (
<button
type="button"
onClick={go.open}
className="ml-auto inline-flex shrink-0 items-center gap-0.5 text-xs text-accent-text underline-offset-2 hover:underline"
>
{go.label} <IconArrowUpRight size={12} />
</button>
)}
</div>
{place && <div className="mt-1 truncate text-xs text-fg-muted">{place}</div>}
{link.ref?.quote && (
<p className="mt-1.5 flex gap-1.5 text-sm italic text-fg-muted">
<IconQuote size={13} className="mt-0.5 shrink-0 text-fg-faint" />
<span className="selectable">{link.ref.quote}</span>
</p>
)}
{link.own && (
<button
type="button"
onClick={() => onForgetSource(link.source)}
className="mt-1.5 text-xs text-fg-subtle underline-offset-2 hover:text-danger hover:underline"
>
Forget all from this source
</button>
)}
</li>
)
})}
</ul>
)
}

export function confidenceWords(c: number): string {
if (c >= 0.9) return 'Very sure'
if (c >= 0.7) return 'Fairly sure'
return 'Not sure yet'
}

export function Confidence({ memory, className }: { memory: Memory; className?: string }) {
const c = memory.confidence ?? 0.8
const n = memory.sources?.length ?? 1
return (
<div className={cn('space-y-1.5', className)}>
<div className="flex items-center justify-between text-sm">
<span className="text-fg">{confidenceWords(c)}</span>
<span className="tabular-nums text-fg-subtle">{Math.round(c * 100)}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-sunken" role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(c * 100)} aria-label="How sure Sentient is">
<div className={cn('h-full rounded-full', c >= 0.9 ? 'bg-success' : c >= 0.7 ? 'bg-accent' : 'bg-warning')} style={{ width: `${Math.round(c * 100)}%` }} />
</div>
<div className="text-xs text-fg-subtle">
{n > 1 ? `${n} places told Sentient this.` : 'One place told Sentient this.'} It gets surer each time you or another source say it again.
</div>
</div>
)
}
18 changes: 17 additions & 1 deletion desktop/src/features/memory/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,23 @@ export function useOptimisticMemoryActions() {
onSettled: settle
})

return useMemo(() => ({ update, remove, move }), [update, remove, move])
const setState = useMutation({
mutationFn: ({ id, state }: { id: number; state: 'current' | 'disputed' }) => api.memories.setState(id, state),
onMutate: async ({ id, state }) => {
const snap = await snapshot()
patchLists((list) => list.map((m) => (m.id === id ? ({ ...m, state, ...(state === 'current' ? { confidence: 1 } : {}) } as Memory) : m)))
return snap
},
onError: (e, _v, snap) => {
restore(snap)
toast.error("Couldn't change that memory", { description: errorMessage(e) })
},
onSuccess: (m) =>
toast.success(m.state === 'disputed' ? 'Sentient will leave this out of chats and tasks' : 'Sentient will use this memory again'),
onSettled: settle
})

return useMemo(() => ({ update, remove, move, setState }), [update, remove, move, setState])
}

function scopeName(scope: string | undefined): string {
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,10 @@ export const api = {
/** §7 memory scopes (#155): Always, Personal, Work and the user's projects, with counts. */
scopes: () => http.get<MemoryScope[]>('/api/memories/scopes'),
move: (id: number, scope: string) => http.patch<Memory>(`/api/memories/${id}`, { scope }),
/** #152: one memory with every source and exactly where it said it. */
get: (id: number) => http.get<Memory>(`/api/memories/${id}`),
/** #152: the user says a memory isn't right (`disputed`, kept out of chats and tasks) or is (`current`). */
setState: (id: number, state: 'current' | 'disputed') => http.patch<Memory>(`/api/memories/${id}`, { state }),
resetScope: (scope: string) => http.delete<{ deleted: number }>(`/api/memories/scope/${enc(scope)}`),
update: (id: number, content: string) => http.put<Memory>(`/api/memories/${id}`, { content }),
delete: (id: number) => http.delete<{ deleted: boolean }>(`/api/memories/${id}`),
Expand Down
39 changes: 39 additions & 0 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2140,6 +2140,43 @@ export interface Memory {
review?: ReviewNote | null
/** Where it is kept (#155): "personal" (default), "work", "always" (every chat) or a project's name. */
scope?: string
/** #152: how sure Sentient is (0..1), from where it came and how often it was learned again. */
confidence?: number
/** #152: only `current` memories go into chats and tasks. */
state?: MemoryState
/** The memory that replaced it (state `superseded`). */
superseded_by?: number | null
/** Its words, on `GET /api/memories/{id}` of a superseded memory. */
superseded_by_content?: string | null
/** Exactly where its own source said it. */
ref?: MemorySourceRef | null
/** Every source, its own first (#152). */
sources?: MemorySourceLink[]
}

export type MemoryState = 'current' | 'disputed' | 'superseded'

/** Exactly where a source said a memory (#152): a chat message, an email or app result, a note, a file. */
export interface MemorySourceRef {
kind?: 'message' | 'email' | 'app' | 'note' | 'file' | string
session_id?: string
message_id?: string
quote?: string | null
title?: string
url?: string
id?: string
app?: string
path?: string
folder_id?: string
file?: string
}

export interface MemorySourceLink {
source: string
ref: MemorySourceRef | null
at: string
/** its own source (the first one) */
own: boolean
}

/** One memory scope (#155) from `GET /api/memories/scopes`. */
Expand Down Expand Up @@ -2207,6 +2244,8 @@ export interface MemoryQuery {
q?: string
source?: string
scope?: string
/** None: current and disputed; `all` adds superseded ones (#152). */
state?: MemoryState | 'all'
limit?: number
offset?: number
}
Expand Down
Loading
Loading