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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/ai/presenter-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type { MCPServerManager } from '../mcp/manager'
import type { ChatRequest } from '../../shared/ipc'
import { recordEvalOpsChatTrace } from '../evalops/services'
import { KESTREL_PROMPT_NAMES, resolveEvalOpsPrompt } from '../evalops/prompts'
import { syncChatThreadMemoryInBackground } from '../evalops/memory-sync'

let contextKitRef: ContextKitClient | null = null

Expand Down Expand Up @@ -230,6 +231,7 @@ function finalize(

// Auto-title thread from first user message
autoTitle(request.threadId, request.messages)
syncChatThreadMemoryInBackground(request.threadId)
}

function autoTitle(
Expand Down
145 changes: 145 additions & 0 deletions src/main/evalops/memory-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { eq } from 'drizzle-orm'
import { getDatabase } from '../db'
import * as schema from '../db/schema'
import { getSettingValue } from './settings'
import { storeEvalOpsMemory } from './services'

export const EVALOPS_MEMORY_SYNC_SETTING_KEY = 'evalops_memory_sync'

type MemorySyncCategory = 'chat' | 'meetings' | 'journal'

interface EvalOpsMemorySyncSettings {
enabled?: boolean
chat?: boolean
meetings?: boolean
journal?: boolean
}

export function getEvalOpsMemorySyncSettings(): Required<EvalOpsMemorySyncSettings> {
const stored = getSettingValue<EvalOpsMemorySyncSettings>(EVALOPS_MEMORY_SYNC_SETTING_KEY) ?? {}
return {
enabled: stored.enabled === true,
chat: stored.chat === true,
meetings: stored.meetings === true,
journal: stored.journal === true
}
}

export async function syncChatThreadMemory(threadId: string): Promise<void> {
if (!isMemorySyncEnabled('chat')) return

const db = getDatabase()
const thread = db.select().from(schema.threads).where(eq(schema.threads.id, threadId)).get()
if (!thread) return

const messages = db.select()
.from(schema.messages)
.where(eq(schema.messages.threadId, threadId))
.orderBy(schema.messages.createdAt)
.all()

if (messages.length === 0) return

const content = truncateMemoryContent([
`Chat thread: ${thread.title}`,
`Thread ID: ${thread.id}`,
'',
...messages.map((message) => `${message.role}: ${message.content}`)
].join('\n'))

await storeEvalOpsMemory({
id: memoryId('chat-thread', thread.id),
scope: 'SCOPE_USER',
content,
type: 'chat_thread',
source: 'kestrel',
confidence: 0.78,
tags: ['kestrel', 'chat', 'chat_thread', `thread:${thread.id}`]
})
}

export async function syncMeetingSummaryMemory(meetingId: string, audioPath?: string): Promise<void> {
if (!isMemorySyncEnabled('meetings')) return

const db = getDatabase()
const meeting = db.select().from(schema.meetings).where(eq(schema.meetings.id, meetingId)).get()
if (!meeting?.summary) return

const content = truncateMemoryContent([
`Meeting: ${meeting.title}`,
`App: ${meeting.app}`,
`Meeting ID: ${meeting.id}`,
audioPath ? `Local audio: ${audioPath}` : '',
'',
meeting.summary,
meeting.transcript ? `\nTranscript excerpt:\n${meeting.transcript.slice(0, 4_000)}` : ''
].filter(Boolean).join('\n'))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filter(Boolean) removes intentional blank line separators

Low Severity

The filter(Boolean) in syncMeetingSummaryMemory and syncJournalEntryMemory is meant to strip conditional empty-string entries (e.g. when audioPath or tldr is absent), but it also silently removes the intentional '' blank-line separators between the metadata header and the content body. This is inconsistent with syncChatThreadMemory, which uses .join('\n') without filter(Boolean) and correctly preserves its blank line. The result is that meeting and journal memory content has no visual separation between metadata and body text.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5c6552f. Configure here.


await storeEvalOpsMemory({
id: memoryId('meeting-summary', meeting.id),
scope: 'SCOPE_USER',
content,
type: 'meeting_summary',
source: 'kestrel',
confidence: 0.84,
tags: ['kestrel', 'meeting', 'meeting_summary', `meeting:${meeting.id}`]
})
}

export async function syncJournalEntryMemory(date: string): Promise<void> {
if (!isMemorySyncEnabled('journal')) return

const db = getDatabase()
const entry = db.select().from(schema.journalEntries).where(eq(schema.journalEntries.date, date)).get()
if (!entry?.content) return

const content = truncateMemoryContent([
`Journal: ${entry.title}`,
`Date: ${entry.date}`,
entry.tldr ? `TLDR: ${entry.tldr}` : '',
'',
entry.content
].filter(Boolean).join('\n'))

await storeEvalOpsMemory({
id: memoryId('journal', entry.date),
scope: 'SCOPE_USER',
content,
type: 'journal',
source: 'kestrel',
confidence: 0.82,
tags: ['kestrel', 'journal', `date:${entry.date}`]
})
}

export function syncChatThreadMemoryInBackground(threadId: string): void {
void syncChatThreadMemory(threadId).catch((err) => {
console.warn('[evalops:memory] Failed to sync chat thread:', err)
})
}

export function syncMeetingSummaryMemoryInBackground(meetingId: string, audioPath?: string): void {
void syncMeetingSummaryMemory(meetingId, audioPath).catch((err) => {
console.warn('[evalops:memory] Failed to sync meeting summary:', err)
})
}

export function syncJournalEntryMemoryInBackground(date: string): void {
void syncJournalEntryMemory(date).catch((err) => {
console.warn('[evalops:memory] Failed to sync journal entry:', err)
})
}

function isMemorySyncEnabled(category: MemorySyncCategory): boolean {
const settings = getEvalOpsMemorySyncSettings()
return settings.enabled && settings[category]
}

function memoryId(kind: string, id: string): string {
return `kestrel-${kind}-${id}`.replace(/[^a-zA-Z0-9_.:-]/g, '-')
}

function truncateMemoryContent(content: string): string {
const maxChars = 16_000
return content.length > maxChars ? `${content.slice(0, maxChars)}\n...[truncated]` : content
}
1 change: 1 addition & 0 deletions src/main/evalops/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export async function storeEvalOpsMemory(request: EvalOpsStoreMemoryRequest): Pr
const config = getEvalOpsConfig()
const client = await getEvalOpsConsumerClient()
return client.memory.store({
id: request.id,
scope: request.scope ?? 'SCOPE_USER',
content: request.content,
type: request.type,
Expand Down
6 changes: 6 additions & 0 deletions src/main/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { eq, desc } from 'drizzle-orm'
import { getDatabase } from '../db'
import * as schema from '../db/schema'
import { getAllSettingValues, getSettingValue, setSettingValue } from '../evalops/settings'
import {
syncChatThreadMemoryInBackground,
syncJournalEntryMemoryInBackground
} from '../evalops/memory-sync'
import type {
Thread, Message, CreateMessage,
Meeting, CreateMeeting,
Expand Down Expand Up @@ -86,6 +90,7 @@ export function registerIpcHandlers(): void {
.set({ updatedAt: new Date(now) })
.where(eq(schema.threads.id, data.threadId))
.run()
syncChatThreadMemoryInBackground(data.threadId)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chat memory sync race can overwrite complete data

Medium Severity

Every chat turn triggers syncChatThreadMemoryInBackground twice on the same memory ID: first from the messages:create IPC handler (capturing only the user message), and then from finalize in presenter-agent.ts (capturing both user and assistant messages). If the first sync's network call is delayed beyond the LLM response time, it can complete after the second sync, overwriting the complete conversation snapshot with a stale, user-message-only version. The renderer calls these sequentially (messages:create then ai:chatStream), confirmed in chatStore.ts and hummingbird/App.tsx.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5c6552f. Configure here.

return { ...message, createdAt: now } as Message
})

Expand Down Expand Up @@ -138,6 +143,7 @@ export function registerIpcHandlers(): void {
set: { title: data.title, tldr: data.tldr, content: data.content }
})
.run()
syncJournalEntryMemoryInBackground(data.date)
return db.select().from(schema.journalEntries)
.where(eq(schema.journalEntries.date, data.date)).get() as unknown as JournalEntry
})
Expand Down
2 changes: 2 additions & 0 deletions src/main/journal/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { v4 as uuid } from 'uuid'
import { getDatabase } from '../db'
import * as schema from '../db/schema'
import { eq } from 'drizzle-orm'
import { syncJournalEntryMemoryInBackground } from '../evalops/memory-sync'

export function registerJournalHandlers(): void {
ipcMain.handle('journal:generate', async (_e, date: string) => {
Expand Down Expand Up @@ -33,6 +34,7 @@ export function registerJournalHandlers(): void {
}

db.insert(schema.journalEntries).values(entry).run()
syncJournalEntryMemoryInBackground(date)
return entry
})
}
2 changes: 2 additions & 0 deletions src/main/meetings/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { transcribeAudio } from './transcriber'
import { summarizeMeeting } from './summarizer'
import type { ContextKitClient } from '../native/contextkit-client'
import type { MeetingStatus } from '../../shared/ipc'
import { syncMeetingSummaryMemoryInBackground } from '../evalops/memory-sync'

let contextKitRef: ContextKitClient | null = null

Expand Down Expand Up @@ -213,6 +214,7 @@ async function transcribeAndSummarize(
.set({ summary })
.where(eq(schema.meetings.id, meetingId))
.run()
syncMeetingSummaryMemoryInBackground(meetingId, audioPath)
console.log(`[meeting] Summary generated: ${summary.length} chars`)
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
Expand Down
89 changes: 89 additions & 0 deletions src/renderer/main/src/components/settings/EvalOpsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ interface StoredEvalOpsConfig {
}
}

interface StoredEvalOpsMemorySync {
enabled?: boolean
chat?: boolean
meetings?: boolean
journal?: boolean
}

const EVALOPS_MEMORY_SYNC_SETTING_KEY = 'evalops_memory_sync'

export function EvalOpsSettings() {
const [status, setStatus] = useState<EvalOpsAuthStatus | null>(null)
const [identityBaseUrl, setIdentityBaseUrl] = useState(EVALOPS_DEFAULT_IDENTITY_BASE_URL)
Expand All @@ -58,6 +67,10 @@ export function EvalOpsSettings() {
const [providerEnvironment, setProviderEnvironment] = useState(EVALOPS_DEFAULT_PROVIDER_REF.environment)
const [providerCredentialName, setProviderCredentialName] = useState(EVALOPS_DEFAULT_PROVIDER_REF.credentialName)
const [providerTeamId, setProviderTeamId] = useState(EVALOPS_DEFAULT_PROVIDER_REF.teamId)
const [memorySyncEnabled, setMemorySyncEnabled] = useState(false)
const [memorySyncChat, setMemorySyncChat] = useState(false)
const [memorySyncMeetings, setMemorySyncMeetings] = useState(false)
const [memorySyncJournal, setMemorySyncJournal] = useState(false)
const [serviceStatuses, setServiceStatuses] = useState<EvalOpsServiceStatus[]>([])
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
Expand All @@ -68,6 +81,7 @@ export function EvalOpsSettings() {

const load = useCallback(async () => {
const stored = await window.api.invoke('settings:get', 'evalops_config') as StoredEvalOpsConfig | null
const memorySync = await window.api.invoke('settings:get', EVALOPS_MEMORY_SYNC_SETTING_KEY) as StoredEvalOpsMemorySync | null
if (stored?.identityBaseUrl) setIdentityBaseUrl(stored.identityBaseUrl)
if (stored?.baseUrl) setBaseUrl(stored.baseUrl)
if (stored?.llmGatewayBaseUrl) setLlmGatewayBaseUrl(stored.llmGatewayBaseUrl)
Expand All @@ -85,6 +99,10 @@ export function EvalOpsSettings() {
if (stored?.providerRef?.environment) setProviderEnvironment(stored.providerRef.environment)
if (stored?.providerRef?.credentialName) setProviderCredentialName(stored.providerRef.credentialName)
if (stored?.providerRef?.teamId) setProviderTeamId(stored.providerRef.teamId)
setMemorySyncEnabled(memorySync?.enabled === true)
setMemorySyncChat(memorySync?.chat === true)
setMemorySyncMeetings(memorySync?.meetings === true)
setMemorySyncJournal(memorySync?.journal === true)
setStatus(await window.api.invoke('evalops:authStatus'))
}, [])

Expand Down Expand Up @@ -117,6 +135,12 @@ export function EvalOpsSettings() {
teamId: providerTeamId.trim()
}
})
await window.api.invoke('settings:set', EVALOPS_MEMORY_SYNC_SETTING_KEY, {
enabled: memorySyncEnabled,
chat: memorySyncChat,
meetings: memorySyncMeetings,
journal: memorySyncJournal
})
setStatus(await window.api.invoke('evalops:authStatus'))
setMessage('Saved EvalOps settings.')
setTimeout(() => setMessage(null), 2000)
Expand All @@ -133,6 +157,10 @@ export function EvalOpsSettings() {
identityBaseUrl,
llmGatewayBaseUrl,
memoryBaseUrl,
memorySyncChat,
memorySyncEnabled,
memorySyncJournal,
memorySyncMeetings,
parsedScopes,
provider,
providerCredentialName,
Expand Down Expand Up @@ -373,6 +401,42 @@ export function EvalOpsSettings() {
/>
</div>

<div className="space-y-4 border-t border-border pt-5">
<div>
<h4 className="text-sm font-medium mb-1">Memory Sync</h4>
<p className="text-xs text-muted-foreground">
Selected chat, meeting, and journal content is stored in EvalOps Memory and leaves this Mac when enabled.
</p>
</div>
<div className="space-y-3">
<CheckboxSetting
label="Sync selected local data to EvalOps Memory"
checked={memorySyncEnabled}
onChange={setMemorySyncEnabled}
/>
<div className="grid grid-cols-3 gap-3">
<CheckboxSetting
label="Chat"
checked={memorySyncChat}
onChange={setMemorySyncChat}
disabled={!memorySyncEnabled}
/>
<CheckboxSetting
label="Meetings"
checked={memorySyncMeetings}
onChange={setMemorySyncMeetings}
disabled={!memorySyncEnabled}
/>
<CheckboxSetting
label="Journal"
checked={memorySyncJournal}
onChange={setMemorySyncJournal}
disabled={!memorySyncEnabled}
/>
</div>
</div>
</div>

<div className="space-y-4 border-t border-border pt-5">
<div className="flex items-center justify-between gap-4">
<div>
Expand Down Expand Up @@ -438,6 +502,31 @@ export function EvalOpsSettings() {
)
}

function CheckboxSetting({
label,
checked,
onChange,
disabled = false
}: {
label: string
checked: boolean
onChange: (value: boolean) => void
disabled?: boolean
}) {
return (
<label className={`flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm ${disabled ? 'opacity-50' : ''}`}>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 accent-primary"
/>
<span>{label}</span>
</label>
)
}

function TextSetting({
label,
value,
Expand Down
1 change: 1 addition & 0 deletions src/shared/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ export interface EvalOpsRecallMemoryRequest {
}

export interface EvalOpsStoreMemoryRequest {
id?: string
scope?: string
content: string
type: string
Expand Down
Loading