diff --git a/src/main/evalops/consumer-sdk/clients.ts b/src/main/evalops/consumer-sdk/clients.ts index e90c35a..9c1b343 100644 --- a/src/main/evalops/consumer-sdk/clients.ts +++ b/src/main/evalops/consumer-sdk/clients.ts @@ -16,6 +16,10 @@ import type { IngestSpansResponse, JsonObject, JsonValue, + MemoryDeleteRequest, + MemoryDeleteResponse, + MemoryListRequest, + MemoryListResponse, MemoryRecallRequest, MemoryRecallResponse, MemoryRecord, @@ -86,6 +90,34 @@ export class MemoryClient { fallback: (reason) => ({ ...offline(reason) }) }) } + + list( + request: MemoryListRequest = {}, + options?: { signal?: AbortSignal } + ): Promise { + return this.transport.request({ + service: 'memory', + operation: 'list', + path: '/memory.v1.MemoryService/List', + body: request, + signal: options?.signal, + fallback: (reason) => ({ memories: [], total: 0, hasMore: false, ...offline(reason) }) + }) + } + + deleteMemory( + request: MemoryDeleteRequest, + options?: { signal?: AbortSignal } + ): Promise { + return this.transport.request({ + service: 'memory', + operation: 'delete', + path: '/memory.v1.MemoryService/Delete', + body: request, + signal: options?.signal, + fallback: noContent + }) + } } export class ApprovalsClient { diff --git a/src/main/evalops/consumer-sdk/types.ts b/src/main/evalops/consumer-sdk/types.ts index 973f993..42d5c3a 100644 --- a/src/main/evalops/consumer-sdk/types.ts +++ b/src/main/evalops/consumer-sdk/types.ts @@ -180,6 +180,18 @@ export interface MemoryRecallRequest extends JsonObject { reviewStatus?: string } +export interface MemoryListRequest extends JsonObject { + scope?: string + projectId?: string + teamId?: string + repository?: string + agent?: string + type?: string + agentId?: string + limit?: number + offset?: number +} + export interface MemoryRecallResponse extends JsonObject { results: Array<{ memory?: MemoryRecord; similarity?: number; graphDistance?: number }> } @@ -188,6 +200,18 @@ export interface MemoryStoreResponse extends JsonObject { memory?: MemoryRecord } +export interface MemoryListResponse extends JsonObject { + memories: MemoryRecord[] + total?: number + hasMore?: boolean +} + +export interface MemoryDeleteRequest extends JsonObject { + id: string +} + +export interface MemoryDeleteResponse extends JsonObject {} + export interface ApprovalRequestRecord extends JsonObject { id?: string workspaceId?: string diff --git a/src/main/evalops/handlers.ts b/src/main/evalops/handlers.ts index e4b0f17..e62af4b 100644 --- a/src/main/evalops/handlers.ts +++ b/src/main/evalops/handlers.ts @@ -2,15 +2,19 @@ import { ipcMain } from 'electron' import { getEvalOpsAuthStatus, loginEvalOps, logoutEvalOps } from './auth' import { registerKestrelAgentInBackground } from './registration' import { + exportEvalOpsMemorySyncCloudCopy, flushEvalOpsMemorySyncQueue, - getEvalOpsMemorySyncQueueStatus + getEvalOpsMemorySyncQueueStatus, + wipeEvalOpsMemorySyncCloudCopy } from './memory-sync' import { annotateEvalOpsTraceQuality, + deleteEvalOpsMemory, getEvalOpsServicesStatus, ingestEvalOpsSpans, listEvalOpsAgents, listEvalOpsApprovals, + listEvalOpsMemory, listEvalOpsSkills, listEvalOpsTraces, recallEvalOpsMemory, @@ -24,10 +28,12 @@ import type { EvalOpsIngestSpansRequest, EvalOpsListApprovalsRequest, EvalOpsListAgentsRequest, + EvalOpsListMemoryRequest, EvalOpsListSkillsRequest, EvalOpsListTracesRequest, EvalOpsLoginOptions, EvalOpsRecallMemoryRequest, + EvalOpsDeleteMemoryRequest, EvalOpsRecordArenaTraceRequest, EvalOpsRecordArenaVoteRequest, EvalOpsSearchSkillsRequest, @@ -54,6 +60,8 @@ export function registerEvalOpsHandlers(): void { await flushEvalOpsMemorySyncQueue({ force: true }) return getEvalOpsMemorySyncQueueStatus() }) + ipcMain.handle('evalops:memorySync:exportCloudCopy', async () => exportEvalOpsMemorySyncCloudCopy()) + ipcMain.handle('evalops:memorySync:wipeCloudCopy', async () => wipeEvalOpsMemorySyncCloudCopy()) ipcMain.handle('evalops:agents:list', async (_event, request?: EvalOpsListAgentsRequest) => { return listEvalOpsAgents(request) }) @@ -66,9 +74,15 @@ export function registerEvalOpsHandlers(): void { ipcMain.handle('evalops:memory:recall', async (_event, request: EvalOpsRecallMemoryRequest) => { return recallEvalOpsMemory(request) }) + ipcMain.handle('evalops:memory:list', async (_event, request?: EvalOpsListMemoryRequest) => { + return listEvalOpsMemory(request) + }) ipcMain.handle('evalops:memory:store', async (_event, request: EvalOpsStoreMemoryRequest) => { return storeEvalOpsMemory(request) }) + ipcMain.handle('evalops:memory:delete', async (_event, request: EvalOpsDeleteMemoryRequest) => { + return deleteEvalOpsMemory(request) + }) ipcMain.handle('evalops:approvals:list', async (_event, request?: EvalOpsListApprovalsRequest) => { return listEvalOpsApprovals(request) }) diff --git a/src/main/evalops/memory-sync.ts b/src/main/evalops/memory-sync.ts index 1eaa4ee..fedd2af 100644 --- a/src/main/evalops/memory-sync.ts +++ b/src/main/evalops/memory-sync.ts @@ -1,8 +1,12 @@ import { asc, eq, lte } from 'drizzle-orm' +import { dialog } from 'electron' +import fs from 'fs/promises' import { getDatabase } from '../db' import * as schema from '../db/schema' +import { getEvalOpsConfig } from './config' import { getSettingValue } from './settings' -import { storeEvalOpsMemory } from './services' +import { deleteEvalOpsMemory, listEvalOpsMemory, storeEvalOpsMemory } from './services' +import type { EvalOpsMemory } from '../../shared/ipc' export const EVALOPS_MEMORY_SYNC_SETTING_KEY = 'evalops_memory_sync' @@ -12,6 +16,7 @@ const QUEUE_BATCH_SIZE = 10 const QUEUE_INTERVAL_MS = 60_000 const RETRY_BASE_MS = 30_000 const RETRY_MAX_MS = 60 * 60_000 +const SYNCED_MEMORY_TYPES = ['chat_thread', 'meeting_summary', 'journal'] as const interface EvalOpsMemorySyncSettings { enabled?: boolean @@ -27,6 +32,17 @@ export interface EvalOpsMemorySyncQueueStatus { lastError?: string } +export interface EvalOpsMemorySyncExportResponse { + cancelled?: boolean + filePath?: string + count: number +} + +export interface EvalOpsMemorySyncWipeResponse { + deleted: number + failed: Array<{ id: string; error: string }> +} + let queueInterval: ReturnType | null = null let queueFlushPromise: Promise | null = null @@ -189,6 +205,48 @@ export async function flushEvalOpsMemorySyncQueue(options: { force?: boolean } = return queueFlushPromise } +export async function exportEvalOpsMemorySyncCloudCopy(): Promise { + const result = await dialog.showSaveDialog({ + title: 'Export EvalOps Memory', + defaultPath: `kestrel-evalops-memory-${new Date().toISOString().slice(0, 10)}.json`, + filters: [{ name: 'JSON', extensions: ['json'] }] + }) + if (result.canceled || !result.filePath) { + return { cancelled: true, count: 0 } + } + + const memories = await listKestrelSyncedMemories() + const config = getEvalOpsConfig() + await fs.writeFile(result.filePath, JSON.stringify({ + exportedAt: new Date().toISOString(), + source: 'kestrel', + agentId: config.agentId, + types: SYNCED_MEMORY_TYPES, + memories + }, null, 2), 'utf8') + + return { filePath: result.filePath, count: memories.length } +} + +export async function wipeEvalOpsMemorySyncCloudCopy(): Promise { + const memories = await listKestrelSyncedMemories() + clearEvalOpsMemorySyncQueue() + let deleted = 0 + const failed: Array<{ id: string; error: string }> = [] + + for (const memory of memories) { + if (!memory.id) continue + try { + await deleteEvalOpsMemory({ id: memory.id }) + deleted++ + } catch (err) { + failed.push({ id: memory.id, error: errorMessage(err) }) + } + } + + return { deleted, failed } +} + async function drainEvalOpsMemorySyncQueue(force: boolean): Promise { const db = getDatabase() const jobs = force @@ -213,6 +271,33 @@ async function drainEvalOpsMemorySyncQueue(force: boolean): Promise { } } +async function listKestrelSyncedMemories(): Promise { + const config = getEvalOpsConfig() + const memories: EvalOpsMemory[] = [] + const limit = 100 + + for (const type of SYNCED_MEMORY_TYPES) { + let offset = 0 + for (;;) { + const response = await listEvalOpsMemory({ + scope: 'SCOPE_USER', + type, + agentId: config.agentId, + limit, + offset + }) + if ((response as { offline?: boolean }).offline) { + throw new Error('evalops_memory_offline') + } + memories.push(...response.memories) + if (!response.hasMore && response.memories.length < limit) break + offset += limit + } + } + + return memories +} + async function runMemorySyncJob(category: MemorySyncCategory, itemId: string, audioPath?: string): Promise { try { switch (category) { @@ -284,6 +369,10 @@ function removeQueuedMemorySync(category: MemorySyncCategory, itemId: string): v .run() } +function clearEvalOpsMemorySyncQueue(): void { + getDatabase().delete(schema.evalopsMemorySyncQueue).run() +} + function isMemorySyncEnabled(category: MemorySyncCategory): boolean { const settings = getEvalOpsMemorySyncSettings() return settings.enabled && settings[category] diff --git a/src/main/evalops/services.ts b/src/main/evalops/services.ts index 5101286..b81b152 100644 --- a/src/main/evalops/services.ts +++ b/src/main/evalops/services.ts @@ -7,10 +7,14 @@ import type { EvalOpsIngestSpansResponse, EvalOpsAnnotateTraceQualityRequest, EvalOpsAnnotateTraceQualityResponse, + EvalOpsDeleteMemoryRequest, + EvalOpsDeleteMemoryResponse, EvalOpsListApprovalsRequest, EvalOpsListApprovalsResponse, EvalOpsListAgentsRequest, EvalOpsListAgentsResponse, + EvalOpsListMemoryRequest, + EvalOpsListMemoryResponse, EvalOpsListSkillsRequest, EvalOpsListSkillsResponse, EvalOpsListTracesRequest, @@ -116,6 +120,27 @@ export async function storeEvalOpsMemory(request: EvalOpsStoreMemoryRequest): Pr }) } +export async function listEvalOpsMemory(request: EvalOpsListMemoryRequest = {}): Promise { + const config = getEvalOpsConfig() + const client = await getEvalOpsConsumerClient() + return client.memory.list({ + scope: request.scope ?? 'SCOPE_USER', + projectId: request.projectId, + teamId: request.teamId, + repository: request.repository, + agent: request.agent, + type: request.type, + agentId: request.agentId ?? config.agentId, + limit: request.limit ?? 100, + offset: request.offset ?? 0 + }) +} + +export async function deleteEvalOpsMemory(request: EvalOpsDeleteMemoryRequest): Promise { + const client = await getEvalOpsConsumerClient() + return client.memory.deleteMemory({ id: request.id }) +} + export async function listEvalOpsApprovals(request: EvalOpsListApprovalsRequest = {}): Promise { const config = getEvalOpsConfig() const client = await getEvalOpsConsumerClient() diff --git a/src/renderer/main/src/components/settings/EvalOpsSettings.tsx b/src/renderer/main/src/components/settings/EvalOpsSettings.tsx index f72d304..77ffd2e 100644 --- a/src/renderer/main/src/components/settings/EvalOpsSettings.tsx +++ b/src/renderer/main/src/components/settings/EvalOpsSettings.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { AlertCircle, Check, LogIn, LogOut, RefreshCw, Save } from 'lucide-react' +import { AlertCircle, Check, Download, LogIn, LogOut, RefreshCw, Save, Trash2 } from 'lucide-react' import { EVALOPS_DEFAULT_AGENT_ID, EVALOPS_DEFAULT_AGENT_REGISTRY_BASE_URL, @@ -15,7 +15,13 @@ import { EVALOPS_DEFAULT_TRACES_BASE_URL, EVALOPS_DEFAULT_WORKSPACE_ID } from '@shared/config' -import type { EvalOpsAuthStatus, EvalOpsMemorySyncQueueStatus, EvalOpsServiceStatus } from '@shared/ipc' +import type { + EvalOpsAuthStatus, + EvalOpsMemorySyncExportResponse, + EvalOpsMemorySyncQueueStatus, + EvalOpsMemorySyncWipeResponse, + EvalOpsServiceStatus +} from '@shared/ipc' interface StoredEvalOpsConfig { identityBaseUrl?: string @@ -247,6 +253,43 @@ export function EvalOpsSettings() { } }, []) + const exportMemoryCloudCopy = useCallback(async () => { + setBusy(true) + setError(null) + try { + const result = await window.api.invoke('evalops:memorySync:exportCloudCopy') as EvalOpsMemorySyncExportResponse + if (!result.cancelled) { + setMessage(`Exported ${result.count} memory ${result.count === 1 ? 'record' : 'records'}.`) + setTimeout(() => setMessage(null), 2000) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + }, []) + + const wipeMemoryCloudCopy = useCallback(async () => { + const confirmed = window.confirm('Delete the Kestrel cloud copy from EvalOps Memory? Local Kestrel data stays on this Mac.') + if (!confirmed) return + setBusy(true) + setError(null) + try { + const result = await window.api.invoke('evalops:memorySync:wipeCloudCopy') as EvalOpsMemorySyncWipeResponse + setMemoryQueueStatus(await window.api.invoke('evalops:memorySync:status')) + if (result.failed.length > 0) { + setError(`Deleted ${result.deleted}; ${result.failed.length} memories could not be deleted.`) + } else { + setMessage(`Deleted ${result.deleted} cloud ${result.deleted === 1 ? 'memory' : 'memories'}.`) + setTimeout(() => setMessage(null), 2000) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + }, []) + return (

EvalOps

@@ -482,6 +525,24 @@ export function EvalOpsSettings() {
)} +
+ + +
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a74cb5f..81b84f1 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -14,11 +14,15 @@ export type IpcChannels = { 'evalops:servicesStatus': { args: []; return: EvalOpsServiceStatus[] } 'evalops:memorySync:status': { args: []; return: EvalOpsMemorySyncQueueStatus } 'evalops:memorySync:flush': { args: []; return: EvalOpsMemorySyncQueueStatus } + 'evalops:memorySync:exportCloudCopy': { args: []; return: EvalOpsMemorySyncExportResponse } + 'evalops:memorySync:wipeCloudCopy': { args: []; return: EvalOpsMemorySyncWipeResponse } 'evalops:agents:list': { args: [request?: EvalOpsListAgentsRequest]; return: EvalOpsListAgentsResponse } 'evalops:skills:list': { args: [request?: EvalOpsListSkillsRequest]; return: EvalOpsListSkillsResponse } 'evalops:skills:search': { args: [request: EvalOpsSearchSkillsRequest]; return: EvalOpsListSkillsResponse } 'evalops:memory:recall': { args: [request: EvalOpsRecallMemoryRequest]; return: EvalOpsRecallMemoryResponse } + 'evalops:memory:list': { args: [request?: EvalOpsListMemoryRequest]; return: EvalOpsListMemoryResponse } 'evalops:memory:store': { args: [request: EvalOpsStoreMemoryRequest]; return: EvalOpsStoreMemoryResponse } + 'evalops:memory:delete': { args: [request: EvalOpsDeleteMemoryRequest]; return: EvalOpsDeleteMemoryResponse } 'evalops:approvals:list': { args: [request?: EvalOpsListApprovalsRequest]; return: EvalOpsListApprovalsResponse } 'evalops:traces:list': { args: [request?: EvalOpsListTracesRequest]; return: EvalOpsListTracesResponse } 'evalops:traces:ingest': { args: [request: EvalOpsIngestSpansRequest]; return: EvalOpsIngestSpansResponse } @@ -375,6 +379,22 @@ export interface EvalOpsStoreMemoryRequest { isPolicy?: boolean } +export interface EvalOpsListMemoryRequest { + scope?: string + projectId?: string + teamId?: string + repository?: string + agent?: string + type?: string + agentId?: string + limit?: number + offset?: number +} + +export interface EvalOpsDeleteMemoryRequest { + id: string +} + export interface EvalOpsMemory { id?: string scope?: string @@ -401,6 +421,14 @@ export interface EvalOpsStoreMemoryResponse { memory?: EvalOpsMemory } +export interface EvalOpsListMemoryResponse { + memories: EvalOpsMemory[] + total?: number + hasMore?: boolean +} + +export interface EvalOpsDeleteMemoryResponse {} + export interface EvalOpsMemorySyncQueueStatus { pending: number failed: number @@ -408,6 +436,17 @@ export interface EvalOpsMemorySyncQueueStatus { lastError?: string } +export interface EvalOpsMemorySyncExportResponse { + cancelled?: boolean + filePath?: string + count: number +} + +export interface EvalOpsMemorySyncWipeResponse { + deleted: number + failed: Array<{ id: string; error: string }> +} + export interface EvalOpsApprovalRequest { id?: string workspaceId?: string