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
32 changes: 32 additions & 0 deletions src/main/evalops/consumer-sdk/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import type {
IngestSpansResponse,
JsonObject,
JsonValue,
MemoryDeleteRequest,
MemoryDeleteResponse,
MemoryListRequest,
MemoryListResponse,
MemoryRecallRequest,
MemoryRecallResponse,
MemoryRecord,
Expand Down Expand Up @@ -86,6 +90,34 @@ export class MemoryClient {
fallback: (reason) => ({ ...offline(reason) })
})
}

list(
request: MemoryListRequest = {},
options?: { signal?: AbortSignal }
): Promise<MemoryListResponse> {
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<MemoryDeleteResponse> {
return this.transport.request({
service: 'memory',
operation: 'delete',
path: '/memory.v1.MemoryService/Delete',
body: request,
signal: options?.signal,
fallback: noContent
})
}
}

export class ApprovalsClient {
Expand Down
24 changes: 24 additions & 0 deletions src/main/evalops/consumer-sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>
}
Expand All @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/main/evalops/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,10 +28,12 @@ import type {
EvalOpsIngestSpansRequest,
EvalOpsListApprovalsRequest,
EvalOpsListAgentsRequest,
EvalOpsListMemoryRequest,
EvalOpsListSkillsRequest,
EvalOpsListTracesRequest,
EvalOpsLoginOptions,
EvalOpsRecallMemoryRequest,
EvalOpsDeleteMemoryRequest,
EvalOpsRecordArenaTraceRequest,
EvalOpsRecordArenaVoteRequest,
EvalOpsSearchSkillsRequest,
Expand All @@ -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)
})
Expand All @@ -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)
})
Expand Down
91 changes: 90 additions & 1 deletion src/main/evalops/memory-sync.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
Expand All @@ -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<typeof setInterval> | null = null
let queueFlushPromise: Promise<void> | null = null

Expand Down Expand Up @@ -189,6 +205,48 @@ export async function flushEvalOpsMemorySyncQueue(options: { force?: boolean } =
return queueFlushPromise
}

export async function exportEvalOpsMemorySyncCloudCopy(): Promise<EvalOpsMemorySyncExportResponse> {
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<EvalOpsMemorySyncWipeResponse> {
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<void> {
const db = getDatabase()
const jobs = force
Expand All @@ -213,6 +271,33 @@ async function drainEvalOpsMemorySyncQueue(force: boolean): Promise<void> {
}
}

async function listKestrelSyncedMemories(): Promise<EvalOpsMemory[]> {
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pagination loop break condition may cause infinite loop

High Severity

The break condition in listKestrelSyncedMemories uses && instead of ||. The expression !response.hasMore && response.memories.length < limit only breaks when both conditions are true. If the server returns hasMore: true with an empty memories array (or any count below limit), !response.hasMore is false, so the && short-circuits to false and the loop never terminates. This is an infinite loop that would hang both the export and wipe operations. The operator needs to be || so that either signal — no more pages or a short page — is sufficient to stop.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f24a831. Configure here.

offset += limit
}
}

return memories
}

async function runMemorySyncJob(category: MemorySyncCategory, itemId: string, audioPath?: string): Promise<void> {
try {
switch (category) {
Expand Down Expand Up @@ -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]
Expand Down
25 changes: 25 additions & 0 deletions src/main/evalops/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import type {
EvalOpsIngestSpansResponse,
EvalOpsAnnotateTraceQualityRequest,
EvalOpsAnnotateTraceQualityResponse,
EvalOpsDeleteMemoryRequest,
EvalOpsDeleteMemoryResponse,
EvalOpsListApprovalsRequest,
EvalOpsListApprovalsResponse,
EvalOpsListAgentsRequest,
EvalOpsListAgentsResponse,
EvalOpsListMemoryRequest,
EvalOpsListMemoryResponse,
EvalOpsListSkillsRequest,
EvalOpsListSkillsResponse,
EvalOpsListTracesRequest,
Expand Down Expand Up @@ -116,6 +120,27 @@ export async function storeEvalOpsMemory(request: EvalOpsStoreMemoryRequest): Pr
})
}

export async function listEvalOpsMemory(request: EvalOpsListMemoryRequest = {}): Promise<EvalOpsListMemoryResponse> {
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<EvalOpsDeleteMemoryResponse> {
const client = await getEvalOpsConsumerClient()
return client.memory.deleteMemory({ id: request.id })
}

export async function listEvalOpsApprovals(request: EvalOpsListApprovalsRequest = {}): Promise<EvalOpsListApprovalsResponse> {
const config = getEvalOpsConfig()
const client = await getEvalOpsConsumerClient()
Expand Down
Loading
Loading