diff --git a/src/main/ai/presenter-agent.ts b/src/main/ai/presenter-agent.ts index 07a5066..4ba9d50 100644 --- a/src/main/ai/presenter-agent.ts +++ b/src/main/ai/presenter-agent.ts @@ -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 @@ -230,6 +231,7 @@ function finalize( // Auto-title thread from first user message autoTitle(request.threadId, request.messages) + syncChatThreadMemoryInBackground(request.threadId) } function autoTitle( diff --git a/src/main/evalops/memory-sync.ts b/src/main/evalops/memory-sync.ts new file mode 100644 index 0000000..0858177 --- /dev/null +++ b/src/main/evalops/memory-sync.ts @@ -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 { + const stored = getSettingValue(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 { + 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 { + 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')) + + 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 { + 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 +} diff --git a/src/main/evalops/services.ts b/src/main/evalops/services.ts index 8288272..5101286 100644 --- a/src/main/evalops/services.ts +++ b/src/main/evalops/services.ts @@ -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, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 6991204..c3ac353 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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, @@ -86,6 +90,7 @@ export function registerIpcHandlers(): void { .set({ updatedAt: new Date(now) }) .where(eq(schema.threads.id, data.threadId)) .run() + syncChatThreadMemoryInBackground(data.threadId) return { ...message, createdAt: now } as Message }) @@ -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 }) diff --git a/src/main/journal/handlers.ts b/src/main/journal/handlers.ts index 0ebc68a..fd10148 100644 --- a/src/main/journal/handlers.ts +++ b/src/main/journal/handlers.ts @@ -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) => { @@ -33,6 +34,7 @@ export function registerJournalHandlers(): void { } db.insert(schema.journalEntries).values(entry).run() + syncJournalEntryMemoryInBackground(date) return entry }) } diff --git a/src/main/meetings/handlers.ts b/src/main/meetings/handlers.ts index 6db85a7..9516123 100644 --- a/src/main/meetings/handlers.ts +++ b/src/main/meetings/handlers.ts @@ -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 @@ -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) diff --git a/src/renderer/main/src/components/settings/EvalOpsSettings.tsx b/src/renderer/main/src/components/settings/EvalOpsSettings.tsx index 88d8207..95ea7d4 100644 --- a/src/renderer/main/src/components/settings/EvalOpsSettings.tsx +++ b/src/renderer/main/src/components/settings/EvalOpsSettings.tsx @@ -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(null) const [identityBaseUrl, setIdentityBaseUrl] = useState(EVALOPS_DEFAULT_IDENTITY_BASE_URL) @@ -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([]) const [busy, setBusy] = useState(false) const [message, setMessage] = useState(null) @@ -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) @@ -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')) }, []) @@ -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) @@ -133,6 +157,10 @@ export function EvalOpsSettings() { identityBaseUrl, llmGatewayBaseUrl, memoryBaseUrl, + memorySyncChat, + memorySyncEnabled, + memorySyncJournal, + memorySyncMeetings, parsedScopes, provider, providerCredentialName, @@ -373,6 +401,42 @@ export function EvalOpsSettings() { /> +
+
+

Memory Sync

+

+ Selected chat, meeting, and journal content is stored in EvalOps Memory and leaves this Mac when enabled. +

+
+
+ +
+ + + +
+
+
+
@@ -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 ( + + ) +} + function TextSetting({ label, value, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index eda23aa..2055fe1 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -356,6 +356,7 @@ export interface EvalOpsRecallMemoryRequest { } export interface EvalOpsStoreMemoryRequest { + id?: string scope?: string content: string type: string