/** * Agent memory, as the panel and the send path see it. * * The server owns the store; this holds the last snapshot read from it and * serializes writes so two quick edits cannot land out of order. * * A failed load never blanks what is already held. An empty list would read as * "the agent has forgotten everything", which is the one wrong answer here: the * user would go looking for lost memory that is sitting safely on disk. */ import { create } from 'zustand'; import { AgentMemoryDisabledError, deleteAgentMemory, fetchAgentMemory, updateAgentMemory, type AgentMemoryEntry, type AgentMemoryScope, } from '@/lib/agentMemoryApi'; interface AgentMemoryState { global: AgentMemoryEntry[]; project: AgentMemoryEntry[]; /** The project path the held `project` entries belong to. */ projectPath: string | null; loading: boolean; loaded: boolean; /** True once the server has reported the feature switched off. */ disabled: boolean; globalFailed: boolean; projectFailed: boolean; error: string | null; load: (projectPath: string | null) => Promise; /** Re-read the store the last load used. */ refresh: () => Promise; saveEntry: ( scope: AgentMemoryScope, memoryId: string, patch: { title?: string; body?: string }, ) => Promise; deleteEntry: (scope: AgentMemoryScope, memoryId: string) => Promise; reset: () => void; } const EMPTY_STATE = { global: [] as AgentMemoryEntry[], project: [] as AgentMemoryEntry[], projectPath: null as string | null, loading: false, loaded: false, disabled: false, globalFailed: false, projectFailed: false, error: null as string | null, }; /** * Only the newest load may write to the store. Turning the feature back on * fires a load before the setting has finished being written, so an older * "disabled" answer can arrive after a newer successful one and latch the * feature off again. */ let loadSequence = 0; /** Serializes writes so a slow first request cannot overwrite a later one. */ let writeChain: Promise = Promise.resolve(); const enqueueWrite = (work: () => Promise): Promise => { const next = writeChain.then(work, work); writeChain = next.catch(() => undefined); return next; }; const listFor = (state: AgentMemoryState, scope: AgentMemoryScope): AgentMemoryEntry[] => ( scope === 'global' ? state.global : state.project ); const withList = ( scope: AgentMemoryScope, entries: AgentMemoryEntry[], ): Partial => ( scope === 'global' ? { global: entries } : { project: entries } ); const errorMessage = (error: unknown, fallback: string): string => ( error instanceof Error && error.message ? error.message : fallback ); export const useAgentMemoryStore = create((set, get) => ({ ...EMPTY_STATE, load: async (projectPath) => { const requestId = ++loadSequence; set({ loading: true, projectPath }); try { const snapshot = await fetchAgentMemory(projectPath); if (requestId !== loadSequence) return; set({ global: snapshot.global, project: snapshot.project, projectPath, globalFailed: snapshot.globalFailed, projectFailed: snapshot.projectFailed, loading: false, loaded: true, disabled: false, error: null, }); } catch (error) { if (requestId !== loadSequence) return; if (error instanceof AgentMemoryDisabledError) { // Switched off is not a failure. Clearing the lists is right here and // only here: with the feature off there is nothing for the user to act // on, and the tab that would show them is gone too. The path is kept so // a later refresh knows which store to re-read. set({ ...EMPTY_STATE, projectPath, disabled: true, loaded: true }); return; } // Whatever was loaded before stays. Only the error is new. set({ loading: false, error: errorMessage(error, 'Failed to load agent memory') }); } }, refresh: async () => { await get().load(get().projectPath); }, saveEntry: async (scope, memoryId, patch) => enqueueWrite(async () => { const previous = listFor(get(), scope); try { const saved = await updateAgentMemory(scope, get().projectPath, memoryId, patch); set(withList(scope, listFor(get(), scope).map((entry) => (entry.id === memoryId ? saved : entry)))); return true; } catch (error) { set({ ...withList(scope, previous), error: errorMessage(error, 'Failed to save memory') }); return false; } }), deleteEntry: async (scope, memoryId) => enqueueWrite(async () => { const previous = listFor(get(), scope); set(withList(scope, previous.filter((entry) => entry.id !== memoryId))); try { await deleteAgentMemory(scope, get().projectPath, memoryId); return true; } catch (error) { set({ ...withList(scope, previous), error: errorMessage(error, 'Failed to delete memory') }); return false; } }), reset: () => { set({ ...EMPTY_STATE }); }, }));