-
Notifications
You must be signed in to change notification settings - Fork 0
Add opt-in EvalOps memory sync #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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')) | ||
|
|
||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Chat memory sync race can overwrite complete dataMedium Severity Every chat turn triggers Additional Locations (1)Reviewed by Cursor Bugbot for commit 5c6552f. Configure here. |
||
| 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 | ||
| }) | ||
|
|
||


There was a problem hiding this comment.
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)insyncMeetingSummaryMemoryandsyncJournalEntryMemoryis meant to strip conditional empty-string entries (e.g. whenaudioPathortldris absent), but it also silently removes the intentional''blank-line separators between the metadata header and the content body. This is inconsistent withsyncChatThreadMemory, which uses.join('\n')withoutfilter(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)
src/main/evalops/memory-sync.ts#L95-L102Reviewed by Cursor Bugbot for commit 5c6552f. Configure here.