From 51b84a5d5b69f76be49ed6f9b9f5736d15e277f0 Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 15:13:21 -0400
Subject: [PATCH 01/12] feat(shared): add agent memory wire types
Promote AgentMemoryEntry and AgentMemorySearchResult to @hyperneo/shared
so the web client and daemon share one source of truth for the
agentMemory.* RPC wire shapes. The daemon repository now imports and
re-exports them; existing daemon imports are unaffected.
---
.../repositories/agent-memory-repository.ts | 21 +++--------
packages/shared/src/mod.ts | 1 +
packages/shared/src/types/memory.ts | 35 +++++++++++++++++++
3 files changed, 41 insertions(+), 16 deletions(-)
create mode 100644 packages/shared/src/types/memory.ts
diff --git a/packages/daemon/src/storage/repositories/agent-memory-repository.ts b/packages/daemon/src/storage/repositories/agent-memory-repository.ts
index 302cec36d1..335bd8d83f 100644
--- a/packages/daemon/src/storage/repositories/agent-memory-repository.ts
+++ b/packages/daemon/src/storage/repositories/agent-memory-repository.ts
@@ -1,22 +1,11 @@
import type { Database as BunDatabase } from 'bun:sqlite';
+import type { AgentMemoryEntry, AgentMemorySearchResult } from '@hyperneo/shared';
import type { ReactiveDatabase } from '../reactive-database';
-export interface AgentMemoryEntry {
- key: string;
- spaceId: string;
- content: string;
- tags: string[];
- createdBySession: string | null;
- createdAt: number;
- updatedAt: number;
- accessCount: number;
- lastAccessedAt: number | null;
-}
-
-export interface AgentMemorySearchResult {
- memory: AgentMemoryEntry;
- rank: number;
-}
+// Wire shapes live in @hyperneo/shared so the web client and daemon share one
+// source of truth. Re-exported here so existing daemon imports
+// (`import { AgentMemoryEntry } from '.../agent-memory-repository'`) keep working.
+export type { AgentMemoryEntry, AgentMemorySearchResult };
export interface AgentMemoryCoreEntry extends AgentMemoryEntry {
score: number;
diff --git a/packages/shared/src/mod.ts b/packages/shared/src/mod.ts
index b75c06e14c..cc6729e6e1 100644
--- a/packages/shared/src/mod.ts
+++ b/packages/shared/src/mod.ts
@@ -28,6 +28,7 @@ export * from './types/tools.ts';
export * from './types/app-mcp-server.ts';
export * from './types/mcp-enablement.ts';
export * from './types/skills.ts';
+export * from './types/memory.ts';
export * from './types/reference.ts';
export * from './types/provider-record.ts';
export * from './live-query-types.ts';
diff --git a/packages/shared/src/types/memory.ts b/packages/shared/src/types/memory.ts
new file mode 100644
index 0000000000..3c76179395
--- /dev/null
+++ b/packages/shared/src/types/memory.ts
@@ -0,0 +1,35 @@
+/**
+ * Agent memory wire types.
+ *
+ * These mirror the on-the-wire shapes returned by the `agentMemory.*` RPC
+ * handlers (`packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts`),
+ * which delegate to `AgentMemoryRepository`. The daemon repository imports
+ * these types from here so there is a single source of truth for the shape
+ * clients receive.
+ *
+ * Note: there is intentionally no `id` field — the internal DB row id (and all
+ * embedding-related columns) are stripped before crossing the wire. A memory
+ * is uniquely identified within a space by its `key`.
+ */
+
+export interface AgentMemoryEntry {
+ key: string;
+ spaceId: string;
+ content: string;
+ tags: string[];
+ createdBySession: string | null;
+ createdAt: number;
+ updatedAt: number;
+ accessCount: number;
+ lastAccessedAt: number | null;
+}
+
+/**
+ * A single ranked search hit. `rank` is a fused reciprocal-rank-fusion score
+ * combining BM25 (FTS) and vector similarity — treat it as an opaque ordering
+ * value, not a normalized 0-1 score.
+ */
+export interface AgentMemorySearchResult {
+ memory: AgentMemoryEntry;
+ rank: number;
+}
From 34a78490c935ce7e6a65c03968ee8c5637199eda Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 15:14:11 -0400
Subject: [PATCH 02/12] feat(web): add memory management UI
Surface a space's agent memories in the UI with a dedicated
/space/:id/memories route and a Memories entry in the space sidebar.
- memory-store: one-shot client over agentMemory.list/search/write/delete
with load-generation race guarding and space attach/detach lifecycle.
- SpaceMemories: header + hybrid search + memory cards (key, content
preview, tags, updated) with create/edit/delete.
- SpaceMemoryEditor: modal form; key locked when editing (daemon upserts
on (spaceId, key)), client validation mirrors the daemon normalize rules.
- Router/signals/island wiring mirrors the existing Goals view.
Space-scoped only; per-agent (mine/space/all) filtering lands with the
per-agent namespacing task. Component tests cover list/search/empty/
create/edit/delete/lifecycle.
---
.../src/components/space/SpaceMemories.tsx | 364 ++++++++++++++++++
.../components/space/SpaceMemoryEditor.tsx | 168 ++++++++
.../space/__tests__/SpaceMemories.test.tsx | 232 +++++++++++
packages/web/src/components/space/index.ts | 3 +
packages/web/src/islands/SpaceDetailPanel.tsx | 30 ++
packages/web/src/islands/SpaceIsland.tsx | 22 ++
packages/web/src/lib/memory-store.ts | 156 ++++++++
packages/web/src/lib/router.ts | 46 +++
packages/web/src/lib/signals.ts | 3 +-
9 files changed, 1023 insertions(+), 1 deletion(-)
create mode 100644 packages/web/src/components/space/SpaceMemories.tsx
create mode 100644 packages/web/src/components/space/SpaceMemoryEditor.tsx
create mode 100644 packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
create mode 100644 packages/web/src/lib/memory-store.ts
diff --git a/packages/web/src/components/space/SpaceMemories.tsx b/packages/web/src/components/space/SpaceMemories.tsx
new file mode 100644
index 0000000000..fcf3bfc17d
--- /dev/null
+++ b/packages/web/src/components/space/SpaceMemories.tsx
@@ -0,0 +1,364 @@
+/**
+ * SpaceMemories Component
+ *
+ * Browse and manage a space's agent memories: list, hybrid search, create,
+ * edit, and delete. Delegates all data access to the `agentMemory.*` RPCs via
+ * `memoryStore`.
+ *
+ * Space-scoped only — per-agent (mine / space / all) filtering lands with the
+ * per-agent namespacing task.
+ */
+
+import { useEffect, useRef, useState } from 'preact/hooks';
+import type { AgentMemoryEntry } from '@hyperneo/shared';
+import { Button } from '../ui/Button';
+import { ConfirmModal } from '../ui/ConfirmModal';
+import { memoryStore } from '../../lib/memory-store';
+import { toast } from '../../lib/toast';
+import { SpaceMemoryEditor } from './SpaceMemoryEditor';
+
+const SEARCH_DEBOUNCE_MS = 250;
+
+interface SpaceMemoriesProps {
+ spaceId: string;
+ /** Route-facing space id (slug or uuid). Kept for parity with sibling views. */
+ navigationSpaceId?: string;
+}
+
+function formatDate(ts: number): string {
+ return new Date(ts).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+interface MemoryCardProps {
+ memory: AgentMemoryEntry;
+ onEdit: (memory: AgentMemoryEntry) => void;
+ onDelete: (memory: AgentMemoryEntry) => void;
+}
+
+function MemoryCard({ memory, onEdit, onDelete }: MemoryCardProps) {
+ return (
+
+
+
+
+ {memory.key}
+
+
+ {memory.content}
+
+
+ Updated {formatDate(memory.updatedAt)}
+ {memory.accessCount > 0 && · {memory.accessCount} reads }
+ {memory.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
onEdit(memory)}
+ class="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/5 hover:text-gray-300"
+ aria-label={`Edit memory ${memory.key}`}
+ data-testid={`memory-edit-${memory.key}`}
+ >
+
+
+
+
+
onDelete(memory)}
+ class="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/5 hover:text-red-400"
+ aria-label={`Delete memory ${memory.key}`}
+ data-testid={`memory-delete-${memory.key}`}
+ >
+
+
+
+
+
+
+
+ );
+}
+
+function PlusIcon() {
+ return (
+
+
+
+ );
+}
+
+function MemoryIcon() {
+ return (
+
+
+
+ );
+}
+
+export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
+ const memories = memoryStore.memories.value;
+ const loading = memoryStore.isLoading.value;
+ const loaded = memoryStore.loaded.value;
+ const error = memoryStore.error.value;
+
+ const [searchInput, setSearchInput] = useState('');
+ const [editorMemory, setEditorMemory] = useState(null);
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [deletingMemory, setDeletingMemory] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const [deleteError, setDeleteError] = useState(null);
+ const debounceRef = useRef | null>(null);
+
+ useEffect(() => {
+ // Reset local UI state on space switch so stale modals/search don't carry over.
+ setSearchInput('');
+ setEditorMemory(null);
+ setEditorOpen(false);
+ setDeletingMemory(null);
+ setDeleteError(null);
+ memoryStore.attach(spaceId).catch(() => {
+ // Error surfaced via memoryStore.error signal.
+ });
+ return () => {
+ memoryStore.detach();
+ };
+ }, [spaceId]);
+
+ // Clear any pending debounced search on unmount.
+ useEffect(
+ () => () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ },
+ []
+ );
+
+ const handleSearchInput = (value: string) => {
+ setSearchInput(value);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ memoryStore.search(value).catch(() => {
+ // Error surfaced via memoryStore.error signal.
+ });
+ }, SEARCH_DEBOUNCE_MS);
+ };
+
+ const handleCreate = () => {
+ setEditorMemory(null);
+ setEditorOpen(true);
+ };
+
+ const handleEdit = (memory: AgentMemoryEntry) => {
+ setEditorMemory(memory);
+ setEditorOpen(true);
+ };
+
+ const handleEditorClose = () => {
+ setEditorOpen(false);
+ setEditorMemory(null);
+ };
+
+ const handleDeleteClick = (memory: AgentMemoryEntry) => {
+ setDeletingMemory(memory);
+ setDeleteError(null);
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!deletingMemory) return;
+ const key = deletingMemory.key;
+ setDeleting(true);
+ setDeleteError(null);
+ try {
+ await memoryStore.deleteMemory(key);
+ setDeletingMemory(null);
+ toast.success(`Memory "${key}" deleted`);
+ } catch (err) {
+ setDeleteError(err instanceof Error ? err.message : 'Failed to delete memory');
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ const handleRetry = () => {
+ memoryStore.reload().catch(() => {
+ // Error surfaced via memoryStore.error signal.
+ });
+ };
+
+ if (loading && !loaded) {
+ return (
+
+
+ Loading memories...
+
+
+ );
+ }
+
+ const trimmedSearch = searchInput.trim();
+
+ return (
+
+
+
+
+
+
+
+ Memories · {memories.length} stored
+
+
+ Persistent facts, conventions, and decisions this space's agents can recall. Search
+ uses the hybrid keyword + semantic backend.
+
+
+
+
}
+ data-testid="memory-create-button"
+ >
+ New Memory
+
+
+
+
+
+
+
+
handleSearchInput((e.target as HTMLInputElement).value)}
+ placeholder="Search memories…"
+ class="w-full rounded-lg border border-white/10 bg-dark-950 py-1.5 pl-8 pr-8 text-sm text-gray-100 placeholder-gray-600 focus:border-blue-500 focus:outline-none"
+ aria-label="Search memories"
+ data-testid="memory-search-input"
+ />
+ {searchInput && (
+
handleSearchInput('')}
+ class="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-gray-500 hover:text-gray-300"
+ aria-label="Clear search"
+ >
+
+
+
+
+ )}
+
+
+
+ {error && (
+
+ {error}
+
+ Retry
+
+
+ )}
+
+ {memories.length === 0 ? (
+
+
+
+
+ {trimmedSearch ? (
+ <>
+
No memories match "{trimmedSearch}".
+
Try a different query or clear the search.
+ >
+ ) : (
+ <>
+
No memories stored yet.
+
+ Create a memory your agents can recall during sessions.
+
+
+
+ New Memory
+
+
+ >
+ )}
+
+ ) : (
+
+ )}
+
+ {editorOpen &&
}
+
+ {deletingMemory && (
+
{
+ setDeletingMemory(null);
+ setDeleteError(null);
+ }}
+ onConfirm={handleDeleteConfirm}
+ title="Delete Memory"
+ message={`Delete the memory "${deletingMemory.key}"? This action cannot be undone.`}
+ confirmText="Delete"
+ confirmButtonVariant="danger"
+ isLoading={deleting}
+ error={deleteError}
+ />
+ )}
+
+ );
+}
diff --git a/packages/web/src/components/space/SpaceMemoryEditor.tsx b/packages/web/src/components/space/SpaceMemoryEditor.tsx
new file mode 100644
index 0000000000..413df31fee
--- /dev/null
+++ b/packages/web/src/components/space/SpaceMemoryEditor.tsx
@@ -0,0 +1,168 @@
+/**
+ * SpaceMemoryEditor Component
+ *
+ * Modal form for creating or editing a single agent memory within the active
+ * space. Create mode (memory === null) lets the user set the key; edit mode
+ * locks the key (the daemon upserts on (spaceId, key), so changing the key
+ * would create a new memory rather than rename — we avoid that footgun).
+ *
+ * Validation mirrors the daemon's normalize* rules so the user gets inline
+ * feedback before the round-trip.
+ */
+
+import { useState } from 'preact/hooks';
+import type { AgentMemoryEntry } from '@hyperneo/shared';
+import { Button } from '../ui/Button';
+import { Modal } from '../ui/Modal';
+import { memoryStore } from '../../lib/memory-store';
+import { toast } from '../../lib/toast';
+
+const KEY_MAX_LENGTH = 200;
+const CONTENT_MAX_LENGTH = 10_000;
+const TAG_MAX_LENGTH = 50;
+
+export interface SpaceMemoryEditorProps {
+ /** Existing memory to edit, or null to create a new one. */
+ memory: AgentMemoryEntry | null;
+ onClose: () => void;
+ onSaved?: (entry: AgentMemoryEntry) => void;
+}
+
+function parseTagsInput(input: string): string[] {
+ return input
+ .split(',')
+ .map((tag) => tag.trim())
+ .filter((tag) => tag.length > 0);
+}
+
+export function SpaceMemoryEditor({ memory, onClose, onSaved }: SpaceMemoryEditorProps) {
+ const isEditing = memory !== null;
+ const [key, setKey] = useState(memory?.key ?? '');
+ const [content, setContent] = useState(memory?.content ?? '');
+ const [tagsInput, setTagsInput] = useState(memory?.tags.join(', ') ?? '');
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSave = async () => {
+ setError(null);
+
+ const trimmedKey = key.trim();
+ const trimmedContent = content.trim();
+ const tags = parseTagsInput(tagsInput);
+
+ if (!isEditing && !trimmedKey) {
+ setError('Key is required.');
+ return;
+ }
+ if (!trimmedContent) {
+ setError('Content is required.');
+ return;
+ }
+ const oversizedTag = tags.find((tag) => tag.length > TAG_MAX_LENGTH);
+ if (oversizedTag) {
+ setError(`Tags must be ${TAG_MAX_LENGTH} characters or fewer.`);
+ return;
+ }
+
+ setSaving(true);
+ try {
+ const entry = await memoryStore.write({
+ key: trimmedKey,
+ content: trimmedContent,
+ tags,
+ });
+ toast.success(`Memory "${entry.key}" saved`);
+ onSaved?.(entry);
+ onClose();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to save memory';
+ setError(message);
+ toast.error(message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Key
+
+
setKey((e.target as HTMLInputElement).value)}
+ disabled={isEditing || saving}
+ maxLength={KEY_MAX_LENGTH}
+ placeholder="unique-key"
+ class="w-full rounded-lg border border-white/10 bg-dark-950 px-3 py-2 font-mono text-sm text-gray-100 placeholder-gray-600 focus:border-blue-500 focus:outline-none disabled:opacity-60"
+ data-testid="memory-key-input"
+ />
+
+ {isEditing
+ ? 'Key cannot be changed — delete and recreate to rename.'
+ : 'A short, unique identifier for this memory within the space.'}
+
+
+
+
+
+
+
+ Tags
+
+
setTagsInput((e.target as HTMLInputElement).value)}
+ disabled={saving}
+ placeholder="convention, feedback, project"
+ class="w-full rounded-lg border border-white/10 bg-dark-950 px-3 py-2 text-sm text-gray-100 placeholder-gray-600 focus:border-blue-500 focus:outline-none"
+ data-testid="memory-tags-input"
+ />
+
Comma-separated keywords that improve retrieval.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ Cancel
+
+
+ {isEditing ? 'Save Changes' : 'Create Memory'}
+
+
+
+
+ );
+}
diff --git a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
new file mode 100644
index 0000000000..397559ca39
--- /dev/null
+++ b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
@@ -0,0 +1,232 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/preact';
+import { signal } from '@preact/signals';
+import type { AgentMemoryEntry } from '@hyperneo/shared';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+let mockMemories: ReturnType>;
+let mockLoading: ReturnType>;
+let mockLoaded: ReturnType>;
+let mockError: ReturnType>;
+const mockAttach = vi.fn();
+const mockDetach = vi.fn();
+const mockSearch = vi.fn();
+const mockWrite = vi.fn();
+const mockDeleteMemory = vi.fn();
+const mockReload = vi.fn();
+
+vi.mock('../../../lib/memory-store', () => ({
+ get memoryStore() {
+ return {
+ memories: mockMemories,
+ isLoading: mockLoading,
+ loaded: mockLoaded,
+ error: mockError,
+ attach: mockAttach,
+ detach: mockDetach,
+ search: mockSearch,
+ write: mockWrite,
+ deleteMemory: mockDeleteMemory,
+ reload: mockReload,
+ };
+ },
+}));
+
+vi.mock('../../../lib/toast', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+mockMemories = signal([]);
+mockLoading = signal(false);
+mockLoaded = signal(true);
+mockError = signal(null);
+
+import { SpaceMemories } from '../SpaceMemories';
+
+function makeMemory(key: string, overrides: Partial = {}): AgentMemoryEntry {
+ return {
+ key,
+ spaceId: 'space-1',
+ content: `Content for ${key}`,
+ tags: [],
+ createdBySession: null,
+ createdAt: 1000,
+ updatedAt: 2000,
+ accessCount: 0,
+ lastAccessedAt: null,
+ ...overrides,
+ };
+}
+
+describe('SpaceMemories', () => {
+ beforeEach(() => {
+ cleanup();
+ mockMemories.value = [];
+ mockLoading.value = false;
+ mockLoaded.value = true;
+ mockError.value = null;
+ mockAttach.mockReset();
+ mockDetach.mockReset();
+ mockSearch.mockReset();
+ mockWrite.mockReset();
+ mockDeleteMemory.mockReset();
+ mockReload.mockReset();
+ mockAttach.mockResolvedValue(undefined);
+ mockSearch.mockResolvedValue(undefined);
+ mockReload.mockResolvedValue(undefined);
+ mockWrite.mockResolvedValue(makeMemory('whatever'));
+ mockDeleteMemory.mockResolvedValue(true);
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('attaches on mount and detaches on unmount', () => {
+ const { unmount } = render( );
+
+ expect(mockAttach).toHaveBeenCalledWith('space-1');
+ unmount();
+ expect(mockDetach).toHaveBeenCalled();
+ });
+
+ it('re-attaches when the space changes', () => {
+ const { rerender } = render( );
+
+ rerender( );
+ expect(mockAttach).toHaveBeenCalledWith('space-2');
+ });
+
+ it('renders a loading state before the first load returns', () => {
+ mockLoading.value = true;
+ mockLoaded.value = false;
+
+ render( );
+
+ expect(screen.getByText('Loading memories...')).toBeTruthy();
+ });
+
+ it('renders the header with a stored count', () => {
+ mockMemories.value = [makeMemory('alpha'), makeMemory('beta')];
+
+ render( );
+
+ expect(screen.getByText('Memories · 2 stored')).toBeTruthy();
+ expect(screen.getByTestId('memory-create-button')).toBeTruthy();
+ });
+
+ it('lists stored memories', () => {
+ mockMemories.value = [makeMemory('alpha', { content: 'Alpha content', tags: ['convention'] })];
+
+ render( );
+
+ expect(screen.getByText('alpha')).toBeTruthy();
+ expect(screen.getByText('Alpha content')).toBeTruthy();
+ expect(screen.getByText('convention')).toBeTruthy();
+ });
+
+ it('shows an empty state when there are no memories', () => {
+ render( );
+
+ expect(screen.getByText('No memories stored yet.')).toBeTruthy();
+ });
+
+ it('shows a no-match state while searching with empty results', () => {
+ mockMemories.value = [];
+
+ render( );
+ fireEvent.input(screen.getByTestId('memory-search-input'), { target: { value: 'xyz' } });
+
+ expect(screen.getByText('No memories match "xyz".')).toBeTruthy();
+ });
+
+ it('debounces typed queries to the store search', async () => {
+ render( );
+
+ fireEvent.input(screen.getByTestId('memory-search-input'), {
+ target: { value: 'convention' },
+ });
+ expect(mockSearch).not.toHaveBeenCalled();
+
+ await waitFor(() => expect(mockSearch).toHaveBeenCalledWith('convention'));
+ });
+
+ it('creates a memory through the editor', async () => {
+ render( );
+
+ fireEvent.click(screen.getByTestId('memory-create-button'));
+ const keyInput = await screen.findByTestId('memory-key-input');
+ fireEvent.input(keyInput, { target: { value: 'new-key' } });
+ fireEvent.input(screen.getByTestId('memory-content-input'), {
+ target: { value: 'A useful fact.' },
+ });
+ fireEvent.input(screen.getByTestId('memory-tags-input'), {
+ target: { value: 'project, feedback' },
+ });
+ fireEvent.click(screen.getByTestId('memory-save-button'));
+
+ await waitFor(() =>
+ expect(mockWrite).toHaveBeenCalledWith({
+ key: 'new-key',
+ content: 'A useful fact.',
+ tags: ['project', 'feedback'],
+ })
+ );
+ });
+
+ it('blocks create when required fields are empty', async () => {
+ render( );
+
+ fireEvent.click(screen.getByTestId('memory-create-button'));
+ await screen.findByTestId('memory-key-input');
+ fireEvent.click(screen.getByTestId('memory-save-button'));
+
+ expect(screen.getByTestId('memory-editor-error')).toBeTruthy();
+ expect(mockWrite).not.toHaveBeenCalled();
+ });
+
+ it('edits an existing memory with a locked key', async () => {
+ mockMemories.value = [makeMemory('alpha', { content: 'old body', tags: ['x'] })];
+
+ render( );
+ fireEvent.click(screen.getByTestId('memory-edit-alpha'));
+
+ const keyInput = await screen.findByTestId('memory-key-input');
+ expect((keyInput as HTMLInputElement).disabled).toBe(true);
+ fireEvent.input(screen.getByTestId('memory-content-input'), {
+ target: { value: 'updated body' },
+ });
+ fireEvent.click(screen.getByTestId('memory-save-button'));
+
+ await waitFor(() =>
+ expect(mockWrite).toHaveBeenCalledWith({
+ key: 'alpha',
+ content: 'updated body',
+ tags: ['x'],
+ })
+ );
+ });
+
+ it('confirms before deleting a memory', async () => {
+ mockMemories.value = [makeMemory('alpha')];
+
+ render( );
+ fireEvent.click(screen.getByTestId('memory-delete-alpha'));
+
+ expect(mockDeleteMemory).not.toHaveBeenCalled();
+ expect(screen.getByText('Delete Memory')).toBeTruthy();
+
+ fireEvent.click(screen.getByText('Delete'));
+
+ await waitFor(() => expect(mockDeleteMemory).toHaveBeenCalledWith('alpha'));
+ });
+
+ it('surfaces a retry control when loading fails', () => {
+ mockError.value = 'Failed to load memories';
+
+ render( );
+
+ expect(screen.getByText('Failed to load memories')).toBeTruthy();
+ fireEvent.click(screen.getByText('Retry'));
+ expect(mockReload).toHaveBeenCalled();
+ });
+});
diff --git a/packages/web/src/components/space/index.ts b/packages/web/src/components/space/index.ts
index c1943edb5c..3184ab30ee 100644
--- a/packages/web/src/components/space/index.ts
+++ b/packages/web/src/components/space/index.ts
@@ -3,6 +3,9 @@
*/
export { SpaceAgentEditor } from './SpaceAgentEditor';
+export { SpaceMemories } from './SpaceMemories';
+export { SpaceMemoryEditor } from './SpaceMemoryEditor';
+export type { SpaceMemoryEditorProps } from './SpaceMemoryEditor';
export { SpaceWorkerAgentList } from './SpaceWorkerAgentList';
export { SpaceCreateDialog } from './SpaceCreateDialog';
export { SpaceCreateTaskDialog } from './SpaceCreateTaskDialog';
diff --git a/packages/web/src/islands/SpaceDetailPanel.tsx b/packages/web/src/islands/SpaceDetailPanel.tsx
index 0f6d5f7a93..ccfd98526f 100644
--- a/packages/web/src/islands/SpaceDetailPanel.tsx
+++ b/packages/web/src/islands/SpaceDetailPanel.tsx
@@ -16,6 +16,7 @@ import {
navigateToSpaceAgent,
navigateToSpaceGoals,
navigateToSpaceForge,
+ navigateToSpaceMemories,
navigateToSpaceSession,
navigateToSpaceSessions,
navigateToSpaceTask,
@@ -255,6 +256,7 @@ export function SpaceDetailPanel({
currentSpaceViewModeSignal.value === 'overview';
const isSpaceAgentSelected = currentSpaceViewModeSignal.value === 'agents';
const isGoalsSelected = currentSpaceViewModeSignal.value === 'goals';
+ const isMemoriesSelected = currentSpaceViewModeSignal.value === 'memories';
const isForgeSelected = currentSpaceViewModeSignal.value === 'forge';
const isTasksSelected = currentSpaceViewModeSignal.value === 'tasks';
const isSessionsSelected = currentSpaceViewModeSignal.value === 'sessions';
@@ -305,6 +307,11 @@ export function SpaceDetailPanel({
onNavigate?.();
}, [routeSpaceId, onNavigate]);
+ const handleMemoriesClick = useCallback(() => {
+ navigateToSpaceMemories(routeSpaceId);
+ onNavigate?.();
+ }, [routeSpaceId, onNavigate]);
+
const handleForgeClick = useCallback(() => {
navigateToSpaceForge(routeSpaceId);
onNavigate?.();
@@ -433,6 +440,29 @@ export function SpaceDetailPanel({
) : undefined
}
/>
+
+
+
+ }
+ />
default: m.SpaceLongHorizonAgents,
}))
);
+const SpaceMemories = lazy(() =>
+ import('../components/space/SpaceMemories').then((m) => ({ default: m.SpaceMemories }))
+);
/** Shared Suspense fallback for lazy-loaded space views. */
const lazyFallback = (
@@ -474,6 +477,25 @@ export default function SpaceIsland({
);
}
+ if (viewMode === 'memories' && space) {
+ return (
+ <>
+
+ {overlay}
+ >
+ );
+ }
+
if (viewMode === 'configure' && space) {
return (
<>
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
new file mode 100644
index 0000000000..4e81c903f8
--- /dev/null
+++ b/packages/web/src/lib/memory-store.ts
@@ -0,0 +1,156 @@
+/**
+ * MemoryStore - Space-scoped agent memory management.
+ *
+ * ARCHITECTURE: One-shot RPC over the `agentMemory.*` handlers.
+ * - Initial state: Fetched via `agentMemory.list` when a space is attached.
+ * - Search: `agentMemory.list` with a `query` delegates to the daemon's hybrid
+ * BM25 + vector backend (no separate `agentMemory.search` call needed — the
+ * management UI wants plain entries, not ranked `{memory, rank}` hits).
+ * - Updates: Re-fetched after each write/delete (there is no LiveQuery for
+ * memories, so we refresh explicitly).
+ *
+ * Signals (reactive state):
+ * - memories: Currently displayed entries for the attached space
+ * - query: The active search query ('' = full list)
+ * - loading, loaded, error: View state
+ */
+
+import { signal } from '@preact/signals';
+import type { AgentMemoryEntry } from '@hyperneo/shared';
+import { Logger } from '@hyperneo/shared';
+import { connectionManager } from './connection-manager';
+
+const logger = new Logger('kai:web:memory-store');
+
+const LIST_LIMIT = 100;
+
+class MemoryStore {
+ /** Currently displayed memories for the attached space. */
+ readonly memories = signal([]);
+
+ /** Active search query — empty string means "show all". */
+ readonly query = signal('');
+
+ /** Loading state. */
+ readonly isLoading = signal(false);
+
+ /**
+ * Flips to `true` once the first load returns so the UI can distinguish
+ * "still loading" from "genuinely zero memories".
+ */
+ readonly loaded = signal(false);
+
+ /** Error state. */
+ readonly error = signal(null);
+
+ /** The space this store is currently bound to. */
+ private spaceId: string | null = null;
+
+ /**
+ * Monotonic load generation. Each load() captures the generation at request
+ * time and discards its result if a newer load (e.g. a faster search) has
+ * started — prevents a slow `list` from clobbering a newer `search` result.
+ */
+ private loadGeneration = 0;
+
+ /** Reset all signals and unbind from the current space. */
+ detach(): void {
+ this.spaceId = null;
+ this.loadGeneration++;
+ this.memories.value = [];
+ this.query.value = '';
+ this.isLoading.value = false;
+ this.loaded.value = false;
+ this.error.value = null;
+ }
+
+ /**
+ * Bind to a space and load its memories. Safe to call on every mount; a
+ * no-op when re-attaching to the same already-loaded space.
+ */
+ attach(spaceId: string): Promise {
+ if (this.spaceId === spaceId && this.loaded.value) return Promise.resolve();
+ this.spaceId = spaceId;
+ this.query.value = '';
+ return this.reload();
+ }
+
+ /**
+ * Re-fetch using the current space + query. Errors are surfaced via the
+ * `error` signal (and re-thrown so callers can chain toasts if desired).
+ */
+ async reload(): Promise {
+ const spaceId = this.spaceId;
+ if (!spaceId) return;
+ const generation = ++this.loadGeneration;
+ this.isLoading.value = true;
+ this.error.value = null;
+ try {
+ const hub = await connectionManager.getHub();
+ const query = this.query.value.trim();
+ const rows = await hub.request('agentMemory.list', {
+ spaceId,
+ query: query || undefined,
+ limit: LIST_LIMIT,
+ });
+ // Discard if a newer load started while this request was in flight.
+ if (generation !== this.loadGeneration) return;
+ this.memories.value = rows ?? [];
+ this.loaded.value = true;
+ } catch (err) {
+ if (generation !== this.loadGeneration) return;
+ this.error.value = err instanceof Error ? err.message : 'Failed to load memories';
+ logger.error('Failed to load memories:', err);
+ throw err;
+ } finally {
+ if (generation === this.loadGeneration) {
+ this.isLoading.value = false;
+ }
+ }
+ }
+
+ /** Set the search query and refresh. Use '' to clear back to the full list. */
+ search(query: string): Promise {
+ this.query.value = query;
+ return this.reload();
+ }
+
+ /**
+ * Create or update a memory. The daemon upserts on (spaceId, key): an
+ * existing key updates content (and tags when provided), a new key creates.
+ * Returns the written entry and refreshes the list.
+ */
+ async write(params: {
+ key: string;
+ content: string;
+ tags?: string[];
+ }): Promise {
+ const spaceId = this.spaceId;
+ if (!spaceId) throw new Error('No space selected.');
+ const hub = await connectionManager.getHub();
+ const entry = await hub.request('agentMemory.write', {
+ spaceId,
+ key: params.key,
+ content: params.content,
+ tags: params.tags,
+ });
+ await this.reload();
+ return entry;
+ }
+
+ /** Delete a memory by key. Returns whether a row was actually deleted. */
+ async deleteMemory(key: string): Promise {
+ const spaceId = this.spaceId;
+ if (!spaceId) throw new Error('No space selected.');
+ const hub = await connectionManager.getHub();
+ const result = await hub.request<{ deleted: boolean }>('agentMemory.delete', {
+ spaceId,
+ key,
+ });
+ await this.reload();
+ return result.deleted;
+ }
+}
+
+/** Singleton store instance. */
+export const memoryStore = new MemoryStore();
diff --git a/packages/web/src/lib/router.ts b/packages/web/src/lib/router.ts
index a3e7d0a0fa..4ee8ce4829 100644
--- a/packages/web/src/lib/router.ts
+++ b/packages/web/src/lib/router.ts
@@ -40,6 +40,7 @@ const SPACE_CONFIGURE_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/configure$/;
const SPACE_CONFIGURE_TAB_ROUTE_PATTERN =
/^\/space\/([a-z0-9-]+)\/configure\/(agents|workflows|settings)$/;
const SPACE_GOALS_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/goals$/;
+const SPACE_MEMORIES_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/memories$/;
const SPACE_FORGE_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/forge$/;
const SPACE_TASKS_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/tasks$/;
const SPACE_TASKS_ARCHIVED_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/tasks\/archived$/;
@@ -102,6 +103,9 @@ export function getSpaceIdFromPath(path: string): string | null {
const goalsMatch = path.match(SPACE_GOALS_ROUTE_PATTERN);
if (goalsMatch) return goalsMatch[1];
+ const memoriesMatch = path.match(SPACE_MEMORIES_ROUTE_PATTERN);
+ if (memoriesMatch) return memoriesMatch[1];
+
const forgeMatch = path.match(SPACE_FORGE_ROUTE_PATTERN);
if (forgeMatch) return forgeMatch[1];
@@ -163,6 +167,11 @@ export function getSpaceGoalsFromPath(path: string): string | null {
return match ? match[1] : null;
}
+export function getSpaceMemoriesFromPath(path: string): string | null {
+ const match = path.match(SPACE_MEMORIES_ROUTE_PATTERN);
+ return match ? match[1] : null;
+}
+
export function getSpaceForgeFromPath(path: string): string | null {
const match = path.match(SPACE_FORGE_ROUTE_PATTERN);
return match ? match[1] : null;
@@ -251,6 +260,10 @@ export function createSpaceGoalsPath(spaceId: string): string {
return `/space/${spaceId}/goals`;
}
+export function createSpaceMemoriesPath(spaceId: string): string {
+ return `/space/${spaceId}/memories`;
+}
+
export function createSpaceForgePath(spaceId: string): string {
return `/space/${spaceId}/forge`;
}
@@ -515,6 +528,29 @@ export function navigateToSpaceGoals(spaceId: string, replace = false): void {
navSectionSignal.value = 'spaces';
}
+export function navigateToSpaceMemories(spaceId: string, replace = false): void {
+ if (routerState.isNavigating) return;
+
+ const targetPath = createSpaceMemoriesPath(spaceId);
+ if (getCurrentPath() !== targetPath) {
+ routerState.isNavigating = true;
+ try {
+ pushPath(targetPath, { spaceId }, replace);
+ } finally {
+ finishNavigation();
+ }
+ }
+
+ setCurrentSpaceRouteId(spaceId);
+ currentSpaceViewModeSignal.value = 'memories';
+ currentSpaceSessionIdSignal.value = null;
+ currentSpaceTaskIdSignal.value = null;
+ currentSpaceTaskViewTabSignal.value = 'thread';
+ currentSpaceAgentHandleSignal.value = null;
+ currentSessionIdSignal.value = null;
+ navSectionSignal.value = 'spaces';
+}
+
export function navigateToSpaceForge(spaceId: string, replace = false): void {
if (routerState.isNavigating) return;
@@ -687,6 +723,7 @@ function applyPathToSignals(path: string, search = window.location.search): stri
? spaceConfigureTab.spaceId
: getSpaceConfigureFromPath(path);
const spaceGoals = getSpaceGoalsFromPath(path);
+ const spaceMemories = getSpaceMemoriesFromPath(path);
const spaceForge = getSpaceForgeFromPath(path);
const spaceTasksTab = getSpaceTasksTabFromPath(path);
const spaceTasks = spaceTasksTab ? spaceTasksTab.spaceId : getSpaceTasksFromPath(path);
@@ -745,6 +782,15 @@ function applyPathToSignals(path: string, search = window.location.search): stri
currentSpaceAgentHandleSignal.value = null;
currentSessionIdSignal.value = null;
navSectionSignal.value = 'spaces';
+ } else if (spaceMemories) {
+ setCurrentSpaceRouteId(spaceMemories);
+ currentSpaceViewModeSignal.value = 'memories';
+ currentSpaceSessionIdSignal.value = null;
+ currentSpaceTaskIdSignal.value = null;
+ currentSpaceTaskViewTabSignal.value = 'thread';
+ currentSpaceAgentHandleSignal.value = null;
+ currentSessionIdSignal.value = null;
+ navSectionSignal.value = 'spaces';
} else if (spaceForge) {
setCurrentSpaceRouteId(spaceForge);
currentSpaceViewModeSignal.value = 'forge';
diff --git a/packages/web/src/lib/signals.ts b/packages/web/src/lib/signals.ts
index 5d8ebf6c8f..1119b05151 100644
--- a/packages/web/src/lib/signals.ts
+++ b/packages/web/src/lib/signals.ts
@@ -36,7 +36,8 @@ export type SpaceViewMode =
| 'sessions'
| 'forge'
| 'configure'
- | 'agents';
+ | 'agents'
+ | 'memories';
export const currentSpaceViewModeSignal = signal('overview');
// Configure sub-tab (agents | workflows | settings) — driven by URL
From fabafef9159e3dc0130e6314cc5a61779f867b6c Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 15:30:49 -0400
Subject: [PATCH 03/12] =?UTF-8?q?fix(memory):=20review=20fixes=20=E2=80=94?=
=?UTF-8?q?=20read-only=20management=20search=20+=20UX=20nits?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P2: management search no longer mutates agent access telemetry.
agentMemory.list now passes recordAccess: false; searchWithOptions
threads a recordAccess flag (default true) so agent search still records
access while management reads leave access_count / last_accessed_at
untouched (those drive core-ranking and stale-pruning).
P3 polish:
- Header shows 'N results' while a search is active, 'N stored' otherwise.
- Drop unused navigationSpaceId prop from SpaceMemories and its call site.
- Drop unused onSaved prop from SpaceMemoryEditor.
- Guard create-mode duplicate keys: warn inline + relabel save to
'Overwrite Memory' so upsert is never silent.
- Fix first-load flash: show the spinner until loaded=true instead of
briefly rendering the empty state.
Tests: +2 daemon (read-only list semantics, handler recordAccess),
+3 web (no-flash, result-count, duplicate-key warning).
---
.../lib/rpc-handlers/agent-memory-handlers.ts | 3 ++
.../repositories/agent-memory-repository.ts | 21 ++++++++---
.../agent-memory-handlers.test.ts | 18 +++++++++
.../agent-memory-repository.test.ts | 32 ++++++++++++++++
.../src/components/space/SpaceMemories.tsx | 16 +++++---
.../components/space/SpaceMemoryEditor.tsx | 19 +++++++---
.../space/__tests__/SpaceMemories.test.tsx | 37 +++++++++++++++++++
packages/web/src/islands/SpaceIsland.tsx | 2 +-
8 files changed, 130 insertions(+), 18 deletions(-)
diff --git a/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts b/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
index 945003d612..7a6ea95256 100644
--- a/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
+++ b/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
@@ -51,6 +51,9 @@ export function setupAgentMemoryHandlers(
query: readOptionalString(payload, 'query') ?? undefined,
limit: readOptionalInteger(payload, 'limit') ?? 50,
offset: readOptionalInteger(payload, 'offset') ?? 0,
+ // Management reads must not mutate agent telemetry (access_count /
+ // last_accessed_at), which drives core-ranking and stale-pruning.
+ recordAccess: false,
});
});
}
diff --git a/packages/daemon/src/storage/repositories/agent-memory-repository.ts b/packages/daemon/src/storage/repositories/agent-memory-repository.ts
index 335bd8d83f..7d7a0c6eba 100644
--- a/packages/daemon/src/storage/repositories/agent-memory-repository.ts
+++ b/packages/daemon/src/storage/repositories/agent-memory-repository.ts
@@ -169,16 +169,21 @@ export class AgentMemoryRepository {
async list(
spaceId: string,
- options?: { query?: string; limit?: number; offset?: number }
+ options?: { query?: string; limit?: number; offset?: number; recordAccess?: boolean }
): Promise {
const limit = normalizeLimit(options?.limit ?? 50, 100);
const offset = Math.max(0, Math.trunc(options?.offset ?? 0));
const query = options?.query?.trim();
if (query) {
- return (await this.searchWithOptions(spaceId, query, { limit, offset, maxLimit: 100 })).map(
- rowToEntry
- );
+ return (
+ await this.searchWithOptions(spaceId, query, {
+ limit,
+ offset,
+ maxLimit: 100,
+ recordAccess: options?.recordAccess,
+ })
+ ).map(rowToEntry);
}
const rows = this.db
@@ -406,7 +411,7 @@ export class AgentMemoryRepository {
private async searchWithOptions(
spaceId: string,
query: string,
- options?: { limit?: number; offset?: number; maxLimit?: number }
+ options?: { limit?: number; offset?: number; maxLimit?: number; recordAccess?: boolean }
): Promise {
const ftsQuery = buildFtsQuery(query);
const limit = normalizeLimit(options?.limit ?? 10, options?.maxLimit ?? 20);
@@ -418,7 +423,11 @@ export class AgentMemoryRepository {
const vectorRows = await this.searchVector(spaceId, query, poolLimit);
const rows = mergeRankedRows(ftsRows, vectorRows).slice(offset, offset + limit);
- if (rows.length > 0) {
+ // `recordAccess` defaults to true: an agent recalling a memory via
+ // `search()` is a genuine access that should refresh its core-ranking /
+ // staleness telemetry. Management reads (`list` with a query) pass
+ // `recordAccess: false` so browsing the panel never mutates telemetry.
+ if (options?.recordAccess !== false && rows.length > 0) {
const now = Date.now();
const bump = this.db.prepare(
`UPDATE space_agent_memory
diff --git a/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts b/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts
index 32a7324313..8b1240e68c 100644
--- a/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts
+++ b/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts
@@ -82,4 +82,22 @@ describe('agent memory RPC handlers', () => {
expect(writes[0]?.createdBySession).toBeNull();
});
+
+ test('list is a read-only management query (recordAccess: false)', async () => {
+ const { messageHub, handlers } = createMessageHubStub();
+ const calls: Array> = [];
+ setupAgentMemoryHandlers(messageHub as never, {
+ memoryRepo: {
+ list: (_spaceId: string, options: Record) => {
+ calls.push(options);
+ return [];
+ },
+ } as never,
+ });
+
+ await handlers.get('agentMemory.list')?.({ spaceId: 'space-a', query: 'conventions' });
+
+ // Management reads must not mutate access_count / last_accessed_at.
+ expect(calls[0]?.recordAccess).toBe(false);
+ });
});
diff --git a/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts b/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts
index 0c0a42eece..7058402bf2 100644
--- a/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts
+++ b/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts
@@ -175,6 +175,38 @@ describe('AgentMemoryRepository', () => {
expect(read?.createdBySession).toBe('session-1');
});
+ test('filtered list does not record access when recordAccess is false', async () => {
+ repo.write({
+ spaceId: 'space-a',
+ key: 'conventions.access',
+ content: 'Management reads must not bump access telemetry.',
+ tags: ['telemetry'],
+ });
+
+ // Management read (recordAccess: false): the hybrid backend returns the
+ // row but must leave access_count / last_accessed_at untouched so browsing
+ // the panel never skews core-ranking or stale-pruning.
+ const readonly = await repo.list('space-a', {
+ query: 'access telemetry',
+ recordAccess: false,
+ });
+ expect(readonly).toHaveLength(1);
+
+ let row = db
+ .prepare(`SELECT access_count, last_accessed_at FROM space_agent_memory WHERE key = ?`)
+ .get('conventions.access') as { access_count: number; last_accessed_at: number | null };
+ expect(row.access_count).toBe(0);
+ expect(row.last_accessed_at).toBeNull();
+
+ // Default filtered list (recordAccess defaults to true) still records
+ // access, matching the agent-facing search semantic.
+ await repo.list('space-a', { query: 'access telemetry' });
+ row = db
+ .prepare(`SELECT access_count FROM space_agent_memory WHERE key = ?`)
+ .get('conventions.access') as { access_count: number };
+ expect(row.access_count).toBe(1);
+ });
+
test('search returns FTS-ranked results', async () => {
repo.write({
spaceId: 'space-a',
diff --git a/packages/web/src/components/space/SpaceMemories.tsx b/packages/web/src/components/space/SpaceMemories.tsx
index fcf3bfc17d..b2c4879e6d 100644
--- a/packages/web/src/components/space/SpaceMemories.tsx
+++ b/packages/web/src/components/space/SpaceMemories.tsx
@@ -21,8 +21,6 @@ const SEARCH_DEBOUNCE_MS = 250;
interface SpaceMemoriesProps {
spaceId: string;
- /** Route-facing space id (slug or uuid). Kept for parity with sibling views. */
- navigationSpaceId?: string;
}
function formatDate(ts: number): string {
@@ -124,9 +122,9 @@ function MemoryIcon() {
export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
const memories = memoryStore.memories.value;
- const loading = memoryStore.isLoading.value;
const loaded = memoryStore.loaded.value;
const error = memoryStore.error.value;
+ const searchActive = memoryStore.query.value.trim() !== '';
const [searchInput, setSearchInput] = useState('');
const [editorMemory, setEditorMemory] = useState(null);
@@ -211,7 +209,7 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
});
};
- if (loading && !loaded) {
+ if (!loaded) {
return (
@@ -231,7 +229,7 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
- Memories · {memories.length} stored
+ Memories · {memories.length} {searchActive ? 'results' : 'stored'}
Persistent facts, conventions, and decisions this space's agents can recall. Search
@@ -341,7 +339,13 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
)}
- {editorOpen &&
}
+ {editorOpen && (
+
memory.key)}
+ onClose={handleEditorClose}
+ />
+ )}
{deletingMemory && (
void;
- onSaved?: (entry: AgentMemoryEntry) => void;
}
function parseTagsInput(input: string): string[] {
@@ -35,7 +36,7 @@ function parseTagsInput(input: string): string[] {
.filter((tag) => tag.length > 0);
}
-export function SpaceMemoryEditor({ memory, onClose, onSaved }: SpaceMemoryEditorProps) {
+export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemoryEditorProps) {
const isEditing = memory !== null;
const [key, setKey] = useState(memory?.key ?? '');
const [content, setContent] = useState(memory?.content ?? '');
@@ -43,10 +44,14 @@ export function SpaceMemoryEditor({ memory, onClose, onSaved }: SpaceMemoryEdito
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
+ const trimmedKey = key.trim();
+ // Create-mode collision: the daemon upserts on (spaceId, key), so saving would
+ // silently overwrite the existing memory's content/tags. Surface it explicitly.
+ const duplicateKey = !isEditing && trimmedKey !== '' && existingKeys.includes(trimmedKey);
+
const handleSave = async () => {
setError(null);
- const trimmedKey = key.trim();
const trimmedContent = content.trim();
const tags = parseTagsInput(tagsInput);
@@ -72,7 +77,6 @@ export function SpaceMemoryEditor({ memory, onClose, onSaved }: SpaceMemoryEdito
tags,
});
toast.success(`Memory "${entry.key}" saved`);
- onSaved?.(entry);
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save memory';
@@ -106,6 +110,11 @@ export function SpaceMemoryEditor({ memory, onClose, onSaved }: SpaceMemoryEdito
? 'Key cannot be changed — delete and recreate to rename.'
: 'A short, unique identifier for this memory within the space.'}
+ {duplicateKey && (
+
+ A memory with this key already exists — saving will overwrite its content and tags.
+
+ )}
@@ -159,7 +168,7 @@ export function SpaceMemoryEditor({ memory, onClose, onSaved }: SpaceMemoryEdito
Cancel
- {isEditing ? 'Save Changes' : 'Create Memory'}
+ {isEditing ? 'Save Changes' : duplicateKey ? 'Overwrite Memory' : 'Create Memory'}
diff --git a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
index 397559ca39..5887d40422 100644
--- a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
+++ b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
@@ -7,6 +7,7 @@ let mockMemories: ReturnType>;
let mockLoading: ReturnType>;
let mockLoaded: ReturnType>;
let mockError: ReturnType>;
+let mockQuery: ReturnType>;
const mockAttach = vi.fn();
const mockDetach = vi.fn();
const mockSearch = vi.fn();
@@ -21,6 +22,7 @@ vi.mock('../../../lib/memory-store', () => ({
isLoading: mockLoading,
loaded: mockLoaded,
error: mockError,
+ query: mockQuery,
attach: mockAttach,
detach: mockDetach,
search: mockSearch,
@@ -39,6 +41,7 @@ mockMemories = signal([]);
mockLoading = signal(false);
mockLoaded = signal(true);
mockError = signal(null);
+mockQuery = signal('');
import { SpaceMemories } from '../SpaceMemories';
@@ -64,6 +67,7 @@ describe('SpaceMemories', () => {
mockLoading.value = false;
mockLoaded.value = true;
mockError.value = null;
+ mockQuery.value = '';
mockAttach.mockReset();
mockDetach.mockReset();
mockSearch.mockReset();
@@ -105,6 +109,18 @@ describe('SpaceMemories', () => {
expect(screen.getByText('Loading memories...')).toBeTruthy();
});
+ it('does not flash the empty state before the first load returns', () => {
+ // loaded=false with isLoading still false is the brief window before attach
+ // flips the loading flag — the spinner must win, not the empty state.
+ mockLoading.value = false;
+ mockLoaded.value = false;
+
+ render( );
+
+ expect(screen.getByText('Loading memories...')).toBeTruthy();
+ expect(screen.queryByText('No memories stored yet.')).toBeNull();
+ });
+
it('renders the header with a stored count', () => {
mockMemories.value = [makeMemory('alpha'), makeMemory('beta')];
@@ -114,6 +130,15 @@ describe('SpaceMemories', () => {
expect(screen.getByTestId('memory-create-button')).toBeTruthy();
});
+ it('renders a result count while a search is active', () => {
+ mockMemories.value = [makeMemory('alpha')];
+ mockQuery.value = 'alpha';
+
+ render( );
+
+ expect(screen.getByText('Memories · 1 results')).toBeTruthy();
+ });
+
it('lists stored memories', () => {
mockMemories.value = [makeMemory('alpha', { content: 'Alpha content', tags: ['convention'] })];
@@ -173,6 +198,18 @@ describe('SpaceMemories', () => {
);
});
+ it('warns (and relabels save) when creating with an existing key', async () => {
+ mockMemories.value = [makeMemory('alpha')];
+
+ render( );
+ fireEvent.click(screen.getByTestId('memory-create-button'));
+ const keyInput = await screen.findByTestId('memory-key-input');
+ fireEvent.input(keyInput, { target: { value: 'alpha' } });
+
+ expect(await screen.findByTestId('memory-duplicate-key-warning')).toBeTruthy();
+ expect(screen.getByTestId('memory-save-button').textContent).toBe('Overwrite Memory');
+ });
+
it('blocks create when required fields are empty', async () => {
render( );
diff --git a/packages/web/src/islands/SpaceIsland.tsx b/packages/web/src/islands/SpaceIsland.tsx
index 4f58979029..8a7d29d1df 100644
--- a/packages/web/src/islands/SpaceIsland.tsx
+++ b/packages/web/src/islands/SpaceIsland.tsx
@@ -487,7 +487,7 @@ export default function SpaceIsland({
-
+
From 18e0210de1cbcd2d1f6a297b2113af44d6399349 Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 15:39:02 -0400
Subject: [PATCH 04/12] =?UTF-8?q?fix(memory):=20address=20review=20P2s=20?=
=?UTF-8?q?=E2=80=94=20pagination,=20dup-key=20block,=20tag=20limit,=20mut?=
=?UTF-8?q?ation=20safety?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Paginate beyond 100: memory-store tracks offset + hasMore (inferred from
full pages, since the backend exposes no total) and exposes loadMore()
that appends + de-dups by key; SpaceMemories shows a Load more button.
- Block create on duplicate key: the editor now rejects the write when the
key already exists (was warning-only); users edit existing memories via
the Edit action instead of silently overwriting via New Memory.
- Reject tag lists over 50 (TAG_MAX_COUNT) mirroring the daemon
normalizeTags slice, so the editor no longer silently drops tags.
- Preserve mutations on refresh failure: write/delete apply their result
optimistically, then best-effort reload; a refresh failure no longer
fails the mutation or tells the user to retry a persisted write.
Tests: +4 store (optimistic write/delete, loadMore dedup, hasMore
inference), +2 component (duplicate block, tag-count limit, load-more).
---
.../src/components/space/SpaceMemories.tsx | 15 ++
.../components/space/SpaceMemoryEditor.tsx | 15 +-
.../space/__tests__/SpaceMemories.test.tsx | 47 +++++-
.../src/lib/__tests__/memory-store.test.ts | 106 +++++++++++++
packages/web/src/lib/memory-store.ts | 140 +++++++++++++++---
5 files changed, 297 insertions(+), 26 deletions(-)
create mode 100644 packages/web/src/lib/__tests__/memory-store.test.ts
diff --git a/packages/web/src/components/space/SpaceMemories.tsx b/packages/web/src/components/space/SpaceMemories.tsx
index b2c4879e6d..3b1e1078b3 100644
--- a/packages/web/src/components/space/SpaceMemories.tsx
+++ b/packages/web/src/components/space/SpaceMemories.tsx
@@ -124,6 +124,8 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
const memories = memoryStore.memories.value;
const loaded = memoryStore.loaded.value;
const error = memoryStore.error.value;
+ const hasMore = memoryStore.hasMore.value;
+ const loadingMore = memoryStore.isLoadingMore.value;
const searchActive = memoryStore.query.value.trim() !== '';
const [searchInput, setSearchInput] = useState('');
@@ -335,6 +337,19 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
onDelete={handleDeleteClick}
/>
))}
+ {hasMore && (
+
+ memoryStore.loadMore().catch(() => {})}
+ loading={loadingMore}
+ data-testid="memory-load-more"
+ >
+ Load more
+
+
+ )}
)}
diff --git a/packages/web/src/components/space/SpaceMemoryEditor.tsx b/packages/web/src/components/space/SpaceMemoryEditor.tsx
index e9fe138840..2e94dc8ef2 100644
--- a/packages/web/src/components/space/SpaceMemoryEditor.tsx
+++ b/packages/web/src/components/space/SpaceMemoryEditor.tsx
@@ -20,6 +20,7 @@ import { toast } from '../../lib/toast';
const KEY_MAX_LENGTH = 200;
const CONTENT_MAX_LENGTH = 10_000;
const TAG_MAX_LENGTH = 50;
+const TAG_MAX_COUNT = 50;
export interface SpaceMemoryEditorProps {
/** Existing memory to edit, or null to create a new one. */
@@ -59,6 +60,12 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
setError('Key is required.');
return;
}
+ if (duplicateKey) {
+ setError(
+ 'A memory with this key already exists. Edit it instead, or choose a different key.'
+ );
+ return;
+ }
if (!trimmedContent) {
setError('Content is required.');
return;
@@ -68,6 +75,10 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
setError(`Tags must be ${TAG_MAX_LENGTH} characters or fewer.`);
return;
}
+ if (tags.length > TAG_MAX_COUNT) {
+ setError(`A memory can have at most ${TAG_MAX_COUNT} tags.`);
+ return;
+ }
setSaving(true);
try {
@@ -112,7 +123,7 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
{duplicateKey && (
- A memory with this key already exists — saving will overwrite its content and tags.
+ A memory with this key already exists — edit it instead, or choose a different key.
)}
@@ -168,7 +179,7 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
Cancel
- {isEditing ? 'Save Changes' : duplicateKey ? 'Overwrite Memory' : 'Create Memory'}
+ {isEditing ? 'Save Changes' : 'Create Memory'}
diff --git a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
index 5887d40422..2f161a51c1 100644
--- a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
+++ b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
@@ -8,12 +8,15 @@ let mockLoading: ReturnType>;
let mockLoaded: ReturnType>;
let mockError: ReturnType>;
let mockQuery: ReturnType>;
+let mockHasMore: ReturnType>;
+let mockIsLoadingMore: ReturnType>;
const mockAttach = vi.fn();
const mockDetach = vi.fn();
const mockSearch = vi.fn();
const mockWrite = vi.fn();
const mockDeleteMemory = vi.fn();
const mockReload = vi.fn();
+const mockLoadMore = vi.fn();
vi.mock('../../../lib/memory-store', () => ({
get memoryStore() {
@@ -23,12 +26,15 @@ vi.mock('../../../lib/memory-store', () => ({
loaded: mockLoaded,
error: mockError,
query: mockQuery,
+ hasMore: mockHasMore,
+ isLoadingMore: mockIsLoadingMore,
attach: mockAttach,
detach: mockDetach,
search: mockSearch,
write: mockWrite,
deleteMemory: mockDeleteMemory,
reload: mockReload,
+ loadMore: mockLoadMore,
};
},
}));
@@ -42,6 +48,8 @@ mockLoading = signal(false);
mockLoaded = signal(true);
mockError = signal(null);
mockQuery = signal('');
+mockHasMore = signal(false);
+mockIsLoadingMore = signal(false);
import { SpaceMemories } from '../SpaceMemories';
@@ -68,13 +76,17 @@ describe('SpaceMemories', () => {
mockLoaded.value = true;
mockError.value = null;
mockQuery.value = '';
+ mockHasMore.value = false;
+ mockIsLoadingMore.value = false;
mockAttach.mockReset();
mockDetach.mockReset();
mockSearch.mockReset();
mockWrite.mockReset();
mockDeleteMemory.mockReset();
mockReload.mockReset();
+ mockLoadMore.mockReset();
mockAttach.mockResolvedValue(undefined);
+ mockLoadMore.mockResolvedValue(undefined);
mockSearch.mockResolvedValue(undefined);
mockReload.mockResolvedValue(undefined);
mockWrite.mockResolvedValue(makeMemory('whatever'));
@@ -198,16 +210,47 @@ describe('SpaceMemories', () => {
);
});
- it('warns (and relabels save) when creating with an existing key', async () => {
+ it('blocks create when the key already exists', async () => {
mockMemories.value = [makeMemory('alpha')];
render( );
fireEvent.click(screen.getByTestId('memory-create-button'));
const keyInput = await screen.findByTestId('memory-key-input');
fireEvent.input(keyInput, { target: { value: 'alpha' } });
+ fireEvent.input(screen.getByTestId('memory-content-input'), { target: { value: 'Body.' } });
expect(await screen.findByTestId('memory-duplicate-key-warning')).toBeTruthy();
- expect(screen.getByTestId('memory-save-button').textContent).toBe('Overwrite Memory');
+ fireEvent.click(screen.getByTestId('memory-save-button'));
+
+ await waitFor(() => expect(screen.getByTestId('memory-editor-error')).toBeTruthy());
+ expect(mockWrite).not.toHaveBeenCalled();
+ });
+
+ it('rejects tag lists exceeding the 50-tag limit', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('memory-create-button'));
+ const keyInput = await screen.findByTestId('memory-key-input');
+ fireEvent.input(keyInput, { target: { value: 'many-tags' } });
+ fireEvent.input(screen.getByTestId('memory-content-input'), { target: { value: 'Body.' } });
+ const manyTags = Array.from({ length: 51 }, (_, i) => `t${i}`).join(', ');
+ fireEvent.input(screen.getByTestId('memory-tags-input'), { target: { value: manyTags } });
+ fireEvent.click(screen.getByTestId('memory-save-button'));
+
+ await waitFor(() =>
+ expect(screen.getByTestId('memory-editor-error').textContent).toContain('at most 50 tags')
+ );
+ expect(mockWrite).not.toHaveBeenCalled();
+ });
+
+ it('offers load-more when a full page is available', () => {
+ mockMemories.value = [makeMemory('alpha')];
+ mockHasMore.value = true;
+
+ render( );
+
+ const loadMore = screen.getByTestId('memory-load-more');
+ fireEvent.click(loadMore);
+ expect(mockLoadMore).toHaveBeenCalled();
});
it('blocks create when required fields are empty', async () => {
diff --git a/packages/web/src/lib/__tests__/memory-store.test.ts b/packages/web/src/lib/__tests__/memory-store.test.ts
new file mode 100644
index 0000000000..9e45545205
--- /dev/null
+++ b/packages/web/src/lib/__tests__/memory-store.test.ts
@@ -0,0 +1,106 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { AgentMemoryEntry } from '@hyperneo/shared';
+
+const mockRequest = vi.fn();
+
+vi.mock('../connection-manager', () => ({
+ connectionManager: {
+ getHub: async () => ({ request: mockRequest }),
+ },
+}));
+
+import { memoryStore } from '../memory-store';
+
+function makeMemory(key: string, overrides: Partial = {}): AgentMemoryEntry {
+ return {
+ key,
+ spaceId: 'space-1',
+ content: `body ${key}`,
+ tags: [],
+ createdBySession: null,
+ createdAt: 1,
+ updatedAt: 1,
+ accessCount: 0,
+ lastAccessedAt: null,
+ ...overrides,
+ };
+}
+
+describe('memoryStore', () => {
+ beforeEach(() => {
+ mockRequest.mockReset();
+ memoryStore.detach();
+ });
+
+ afterEach(() => {
+ memoryStore.detach();
+ });
+
+ it('optimistically reflects a write even when the follow-up reload fails', async () => {
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.list') return [makeMemory('alpha')];
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+ expect(memoryStore.memories.value.map((m) => m.key)).toEqual(['alpha']);
+
+ // write succeeds, but the reconciling reload (list) fails.
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.write') return makeMemory('beta', { updatedAt: 5 });
+ if (method === 'agentMemory.list') throw new Error('connection dropped');
+ throw new Error(`unexpected ${method}`);
+ });
+
+ const entry = await memoryStore.write({ key: 'beta', content: 'x' });
+ expect(entry.key).toBe('beta');
+
+ const keys = memoryStore.memories.value.map((m) => m.key);
+ expect(keys).toContain('beta'); // optimistic upsert survived the failed reload
+ expect(keys).toContain('alpha');
+ // The mutation did not throw and no "retry the write" error is surfaced.
+ expect(memoryStore.error.value).toBeNull();
+ });
+
+ it('optimistically removes a deleted memory', async () => {
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.list') return [makeMemory('alpha'), makeMemory('beta')];
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+ expect(memoryStore.memories.value.map((m) => m.key)).toEqual(['alpha', 'beta']);
+
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.delete') return { deleted: true };
+ if (method === 'agentMemory.list') return [makeMemory('beta')];
+ throw new Error(`unexpected ${method}`);
+ });
+
+ const deleted = await memoryStore.deleteMemory('alpha');
+ expect(deleted).toBe(true);
+ expect(memoryStore.memories.value.map((m) => m.key)).toEqual(['beta']);
+ });
+
+ it('loadMore appends the next page and de-duplicates by key', async () => {
+ mockRequest.mockImplementation(async (_method: string, params: { offset?: number }) => {
+ const offset = params?.offset ?? 0;
+ // A full first page signals more may exist; a short second page ends it.
+ if (offset === 0) return Array.from({ length: 100 }, (_, i) => makeMemory(`k${i}`));
+ return [makeMemory('k100'), makeMemory('k0')]; // k0 duplicates the first page
+ });
+
+ await memoryStore.attach('space-1');
+ expect(memoryStore.memories.value).toHaveLength(100);
+ expect(memoryStore.hasMore.value).toBe(true);
+
+ await memoryStore.loadMore();
+ // 100 + 1 fresh (k100), k0 de-duped against the first page.
+ expect(memoryStore.memories.value).toHaveLength(101);
+ expect(memoryStore.hasMore.value).toBe(false);
+ });
+
+ it('infers hasMore from page size and clears it on a short page', async () => {
+ mockRequest.mockResolvedValue([makeMemory('only')]);
+ await memoryStore.attach('space-1');
+ expect(memoryStore.hasMore.value).toBe(false);
+ });
+});
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
index 4e81c903f8..757a3a63fd 100644
--- a/packages/web/src/lib/memory-store.ts
+++ b/packages/web/src/lib/memory-store.ts
@@ -5,13 +5,18 @@
* - Initial state: Fetched via `agentMemory.list` when a space is attached.
* - Search: `agentMemory.list` with a `query` delegates to the daemon's hybrid
* BM25 + vector backend (no separate `agentMemory.search` call needed — the
- * management UI wants plain entries, not ranked `{memory, rank}` hits).
- * - Updates: Re-fetched after each write/delete (there is no LiveQuery for
- * memories, so we refresh explicitly).
+ * management UI wants plain entries, not ranked `{memory, rank}` hits). The
+ * handler passes `recordAccess: false` so browsing stays read-only.
+ * - Pagination: spaces may hold more memories than the page size; `loadMore()`
+ * fetches and appends the next page. The backend exposes no total, so
+ * `hasMore` is inferred from whether the last page was full.
+ * - Updates: Mutations are applied optimistically, then a best-effort reload
+ * reconciles. A refresh failure never fails the mutation.
*
* Signals (reactive state):
* - memories: Currently displayed entries for the attached space
* - query: The active search query ('' = full list)
+ * - hasMore: More pages may be available behind the current view
* - loading, loaded, error: View state
*/
@@ -22,7 +27,7 @@ import { connectionManager } from './connection-manager';
const logger = new Logger('kai:web:memory-store');
-const LIST_LIMIT = 100;
+const PAGE_SIZE = 100;
class MemoryStore {
/** Currently displayed memories for the attached space. */
@@ -31,9 +36,15 @@ class MemoryStore {
/** Active search query — empty string means "show all". */
readonly query = signal('');
- /** Loading state. */
+ /** More pages may be available behind the currently loaded view. */
+ readonly hasMore = signal(false);
+
+ /** Loading state for the current view (initial load or a refresh). */
readonly isLoading = signal(false);
+ /** Loading state for an appended `loadMore` page (does not block the view). */
+ readonly isLoadingMore = signal(false);
+
/**
* Flips to `true` once the first load returns so the UI can distinguish
* "still loading" from "genuinely zero memories".
@@ -46,20 +57,26 @@ class MemoryStore {
/** The space this store is currently bound to. */
private spaceId: string | null = null;
+ /** Offset of the next page to fetch via `loadMore()`. */
+ private offset = 0;
+
/**
- * Monotonic load generation. Each load() captures the generation at request
- * time and discards its result if a newer load (e.g. a faster search) has
- * started — prevents a slow `list` from clobbering a newer `search` result.
+ * Monotonic load generation. Each fetch captures the generation at request
+ * time and discards its result if a newer fetch started — prevents a slow
+ * page from clobbering a newer replace/append.
*/
private loadGeneration = 0;
/** Reset all signals and unbind from the current space. */
detach(): void {
this.spaceId = null;
+ this.offset = 0;
this.loadGeneration++;
this.memories.value = [];
this.query.value = '';
+ this.hasMore.value = false;
this.isLoading.value = false;
+ this.isLoadingMore.value = false;
this.loaded.value = false;
this.error.value = null;
}
@@ -76,26 +93,21 @@ class MemoryStore {
}
/**
- * Re-fetch using the current space + query. Errors are surfaced via the
- * `error` signal (and re-thrown so callers can chain toasts if desired).
+ * Re-fetch the first page (offset 0) and replace the view. Errors are
+ * surfaced via the `error` signal and re-thrown so callers can chain toasts.
*/
async reload(): Promise {
const spaceId = this.spaceId;
if (!spaceId) return;
+ this.offset = 0;
const generation = ++this.loadGeneration;
this.isLoading.value = true;
this.error.value = null;
try {
- const hub = await connectionManager.getHub();
- const query = this.query.value.trim();
- const rows = await hub.request('agentMemory.list', {
- spaceId,
- query: query || undefined,
- limit: LIST_LIMIT,
- });
- // Discard if a newer load started while this request was in flight.
+ const rows = await this.fetchPage(spaceId, 0);
if (generation !== this.loadGeneration) return;
- this.memories.value = rows ?? [];
+ this.memories.value = rows;
+ this.applyHasMore(rows.length);
this.loaded.value = true;
} catch (err) {
if (generation !== this.loadGeneration) return;
@@ -109,6 +121,38 @@ class MemoryStore {
}
}
+ /**
+ * Fetch and append the next page. No-op when no more pages are expected or
+ * a fetch is already in flight. Refresh failures are swallowed (best-effort)
+ * — the already-loaded view stays usable.
+ */
+ async loadMore(): Promise {
+ const spaceId = this.spaceId;
+ if (!spaceId || !this.hasMore.value || this.isLoadingMore.value) return;
+ const offset = this.offset + PAGE_SIZE;
+ const generation = ++this.loadGeneration;
+ this.isLoadingMore.value = true;
+ try {
+ const rows = await this.fetchPage(spaceId, offset);
+ if (generation !== this.loadGeneration) return;
+ // Append, de-duplicating by key in case offsets shifted between fetches.
+ const seen = new Set(this.memories.value.map((m) => m.key));
+ const fresh = rows.filter((m) => !seen.has(m.key));
+ this.memories.value = [...this.memories.value, ...fresh];
+ this.offset = offset;
+ this.applyHasMore(rows.length);
+ } catch (err) {
+ if (generation !== this.loadGeneration) return;
+ // Best-effort: surface the error but keep the loaded view intact.
+ this.error.value = err instanceof Error ? err.message : 'Failed to load more memories';
+ logger.error('Failed to load more memories:', err);
+ } finally {
+ if (generation === this.loadGeneration) {
+ this.isLoadingMore.value = false;
+ }
+ }
+ }
+
/** Set the search query and refresh. Use '' to clear back to the full list. */
search(query: string): Promise {
this.query.value = query;
@@ -118,7 +162,8 @@ class MemoryStore {
/**
* Create or update a memory. The daemon upserts on (spaceId, key): an
* existing key updates content (and tags when provided), a new key creates.
- * Returns the written entry and refreshes the list.
+ * The written entry is applied optimistically; a refresh then reconciles.
+ * A refresh failure does NOT fail the write — the mutation already succeeded.
*/
async write(params: {
key: string;
@@ -134,7 +179,9 @@ class MemoryStore {
content: params.content,
tags: params.tags,
});
- await this.reload();
+ // Optimistically reflect the write even if the refresh below fails.
+ this.upsertEntry(entry);
+ await this.refreshBestEffort();
return entry;
}
@@ -147,9 +194,58 @@ class MemoryStore {
spaceId,
key,
});
- await this.reload();
+ if (result.deleted) this.removeEntry(key);
+ await this.refreshBestEffort();
return result.deleted;
}
+
+ // ---------------------------------------------------------------------------
+ // Internal helpers
+ // ---------------------------------------------------------------------------
+
+ private async fetchPage(spaceId: string, offset: number): Promise {
+ const hub = await connectionManager.getHub();
+ const query = this.query.value.trim();
+ const rows = await hub.request('agentMemory.list', {
+ spaceId,
+ query: query || undefined,
+ limit: PAGE_SIZE,
+ offset,
+ });
+ return rows ?? [];
+ }
+
+ /** A full page means more may exist; a short page means we've reached the end. */
+ private applyHasMore(returned: number): void {
+ this.hasMore.value = returned >= PAGE_SIZE;
+ }
+
+ /** Re-fetch the first page to reconcile, swallowing refresh-only failures. */
+ private async refreshBestEffort(): Promise {
+ try {
+ await this.reload();
+ } catch {
+ // The mutation already succeeded and the optimistic update above keeps
+ // the UI consistent. Suppress the refresh error so the user is never
+ // told to retry a write/delete that already persisted; the next
+ // load/search reconciles.
+ this.error.value = null;
+ }
+ }
+
+ private upsertEntry(entry: AgentMemoryEntry): void {
+ const others = this.memories.value.filter((m) => m.key !== entry.key);
+ this.memories.value = [...others, entry].sort(compareMemories);
+ }
+
+ private removeEntry(key: string): void {
+ this.memories.value = this.memories.value.filter((m) => m.key !== key);
+ }
+}
+
+/** Match the daemon's list ordering: updated_at DESC, then key ASC. */
+function compareMemories(a: AgentMemoryEntry, b: AgentMemoryEntry): number {
+ return b.updatedAt - a.updatedAt || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
}
/** Singleton store instance. */
From 461a9c0b13fc3403b8b1ab18af37c11e2779affe Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 15:47:07 -0400
Subject: [PATCH 05/12] =?UTF-8?q?fix(memory):=20address=20review=20P2s=20r?=
=?UTF-8?q?ound=203=20=E2=80=94=20error/load=20edges,=20dup-key=20auth,=20?=
=?UTF-8?q?view=20preservation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Show the error+retry state when the initial load fails (was an indefinite
spinner, since loaded stays false): guard is now !loaded && !error.
- Clear pending debounced searches on space switch (Preact reuses the
component across spaces, so the old timer fired against the new space).
- Authoritative duplicate-key check: editor now also queries the backend
read-only (agentMemory.read recordAccess:false) so filtered-out /
paginated keys are flagged, not just the loaded set.
- Block editor dismissal (backdrop/Escape/close) while a save is pending
so it can't race a newly-opened editor via the shared onClose.
- Preserve the Memories view when switching spaces from the ContextPanel
space switcher (was falling through to Overview).
Tests: +1 daemon (read recordAccess passthrough), +2 store (exists
read-only + transport-error fallback), +1 component (remote duplicate).
---
.../lib/rpc-handlers/agent-memory-handlers.ts | 11 ++++-
.../agent-memory-handlers.test.ts | 20 ++++++++++
.../src/components/space/SpaceMemories.tsx | 7 +++-
.../components/space/SpaceMemoryEditor.tsx | 40 +++++++++++++++++--
.../space/__tests__/SpaceMemories.test.tsx | 17 ++++++++
packages/web/src/islands/ContextPanel.tsx | 4 ++
.../src/lib/__tests__/memory-store.test.ts | 30 ++++++++++++++
packages/web/src/lib/memory-store.ts | 22 ++++++++++
8 files changed, 146 insertions(+), 5 deletions(-)
diff --git a/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts b/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
index 7a6ea95256..ffe573acc2 100644
--- a/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
+++ b/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
@@ -37,7 +37,9 @@ export function setupAgentMemoryHandlers(
messageHub.onRequest('agentMemory.read', async (payload: unknown) => {
const request = parseSpaceScopedRequest(payload);
- return deps.memoryRepo.read(request.spaceId, readRequiredString(payload, 'key'));
+ return deps.memoryRepo.read(request.spaceId, readRequiredString(payload, 'key'), {
+ recordAccess: readOptionalBoolean(payload, 'recordAccess'),
+ });
});
messageHub.onRequest('agentMemory.delete', async (payload: unknown) => {
@@ -103,6 +105,13 @@ function readOptionalInteger(payload: unknown, key: string): number | undefined
return value;
}
+function readOptionalBoolean(payload: unknown, key: string): boolean | undefined {
+ const value = readRecord(payload)[key];
+ if (value === undefined) return undefined;
+ if (typeof value !== 'boolean') throw new Error(`${key} must be a boolean.`);
+ return value;
+}
+
function readRecord(payload: unknown): Record {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Request payload must be an object.');
diff --git a/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts b/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts
index 8b1240e68c..65cbae2566 100644
--- a/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts
+++ b/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts
@@ -100,4 +100,24 @@ describe('agent memory RPC handlers', () => {
// Management reads must not mutate access_count / last_accessed_at.
expect(calls[0]?.recordAccess).toBe(false);
});
+
+ test('read forwards recordAccess from the payload', async () => {
+ const { messageHub, handlers } = createMessageHubStub();
+ const calls: Array<{ recordAccess: boolean | undefined }> = [];
+ setupAgentMemoryHandlers(messageHub as never, {
+ memoryRepo: {
+ read: (_spaceId: string, _key: string, options?: { recordAccess?: boolean }) => {
+ calls.push({ recordAccess: options?.recordAccess });
+ return null;
+ },
+ } as never,
+ });
+
+ await handlers.get('agentMemory.read')?.({ spaceId: 'space-a', key: 'k', recordAccess: false });
+ await handlers.get('agentMemory.read')?.({ spaceId: 'space-a', key: 'k' });
+
+ // Explicit false is forwarded; absent leaves the repo default (record).
+ expect(calls[0]?.recordAccess).toBe(false);
+ expect(calls[1]?.recordAccess).toBeUndefined();
+ });
});
diff --git a/packages/web/src/components/space/SpaceMemories.tsx b/packages/web/src/components/space/SpaceMemories.tsx
index 3b1e1078b3..8da84960c0 100644
--- a/packages/web/src/components/space/SpaceMemories.tsx
+++ b/packages/web/src/components/space/SpaceMemories.tsx
@@ -143,10 +143,15 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
setEditorOpen(false);
setDeletingMemory(null);
setDeleteError(null);
+ // Cancel any in-flight debounced search so it can't fire against the new
+ // space after detach/attach (Preact reuses the component across spaces, so
+ // the empty-deps unmount cleanup below does not run on a space switch).
+ if (debounceRef.current) clearTimeout(debounceRef.current);
memoryStore.attach(spaceId).catch(() => {
// Error surfaced via memoryStore.error signal.
});
return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
memoryStore.detach();
};
}, [spaceId]);
@@ -211,7 +216,7 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
});
};
- if (!loaded) {
+ if (!loaded && !error) {
return (
diff --git a/packages/web/src/components/space/SpaceMemoryEditor.tsx b/packages/web/src/components/space/SpaceMemoryEditor.tsx
index 2e94dc8ef2..081c6e273c 100644
--- a/packages/web/src/components/space/SpaceMemoryEditor.tsx
+++ b/packages/web/src/components/space/SpaceMemoryEditor.tsx
@@ -10,7 +10,7 @@
* feedback before the round-trip.
*/
-import { useState } from 'preact/hooks';
+import { useEffect, useState } from 'preact/hooks';
import type { AgentMemoryEntry } from '@hyperneo/shared';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
@@ -21,6 +21,7 @@ const KEY_MAX_LENGTH = 200;
const CONTENT_MAX_LENGTH = 10_000;
const TAG_MAX_LENGTH = 50;
const TAG_MAX_COUNT = 50;
+const KEY_CHECK_DEBOUNCE_MS = 250;
export interface SpaceMemoryEditorProps {
/** Existing memory to edit, or null to create a new one. */
@@ -44,11 +45,38 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
const [tagsInput, setTagsInput] = useState(memory?.tags.join(', ') ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState
(null);
+ // Authoritative duplicate flag resolved against the backend, since the
+ // loaded `existingKeys` may be filtered (search active) or paginated.
+ const [remoteDuplicate, setRemoteDuplicate] = useState(false);
const trimmedKey = key.trim();
// Create-mode collision: the daemon upserts on (spaceId, key), so saving would
// silently overwrite the existing memory's content/tags. Surface it explicitly.
- const duplicateKey = !isEditing && trimmedKey !== '' && existingKeys.includes(trimmedKey);
+ // Combine instant local knowledge with the authoritative remote check.
+ const duplicateKey =
+ !isEditing && trimmedKey !== '' && (existingKeys.includes(trimmedKey) || remoteDuplicate);
+
+ // Debounced authoritative existence check for keys not in the loaded set.
+ useEffect(() => {
+ if (isEditing || trimmedKey === '' || existingKeys.includes(trimmedKey)) {
+ setRemoteDuplicate(false);
+ return;
+ }
+ let cancelled = false;
+ const handle = setTimeout(async () => {
+ const exists = await memoryStore.exists(trimmedKey);
+ if (!cancelled) setRemoteDuplicate(exists);
+ }, KEY_CHECK_DEBOUNCE_MS);
+ return () => {
+ cancelled = true;
+ clearTimeout(handle);
+ };
+ }, [trimmedKey, isEditing, existingKeys]);
+
+ // Block dismissal (backdrop / Escape / header close) while a save is in flight
+ // so the pending RPC can't race a newly-opened editor through the shared
+ // onClose callback.
+ const guardedClose = saving ? () => undefined : onClose;
const handleSave = async () => {
setError(null);
@@ -99,7 +127,13 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
};
return (
-
+
diff --git a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
index 2f161a51c1..013d1723aa 100644
--- a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
+++ b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
@@ -17,6 +17,7 @@ const mockWrite = vi.fn();
const mockDeleteMemory = vi.fn();
const mockReload = vi.fn();
const mockLoadMore = vi.fn();
+const mockExists = vi.fn();
vi.mock('../../../lib/memory-store', () => ({
get memoryStore() {
@@ -35,6 +36,7 @@ vi.mock('../../../lib/memory-store', () => ({
deleteMemory: mockDeleteMemory,
reload: mockReload,
loadMore: mockLoadMore,
+ exists: mockExists,
};
},
}));
@@ -85,8 +87,10 @@ describe('SpaceMemories', () => {
mockDeleteMemory.mockReset();
mockReload.mockReset();
mockLoadMore.mockReset();
+ mockExists.mockReset();
mockAttach.mockResolvedValue(undefined);
mockLoadMore.mockResolvedValue(undefined);
+ mockExists.mockResolvedValue(false);
mockSearch.mockResolvedValue(undefined);
mockReload.mockResolvedValue(undefined);
mockWrite.mockResolvedValue(makeMemory('whatever'));
@@ -226,6 +230,19 @@ describe('SpaceMemories', () => {
expect(mockWrite).not.toHaveBeenCalled();
});
+ it('flags a duplicate found via the authoritative (read-only) check', async () => {
+ // Key is not in the loaded set, but exists on the backend.
+ mockExists.mockResolvedValue(true);
+
+ render( );
+ fireEvent.click(screen.getByTestId('memory-create-button'));
+ const keyInput = await screen.findByTestId('memory-key-input');
+ fireEvent.input(keyInput, { target: { value: 'hidden-key' } });
+
+ expect(await screen.findByTestId('memory-duplicate-key-warning')).toBeTruthy();
+ expect(mockExists).toHaveBeenCalledWith('hidden-key');
+ });
+
it('rejects tag lists exceeding the 50-tag limit', async () => {
render( );
fireEvent.click(screen.getByTestId('memory-create-button'));
diff --git a/packages/web/src/islands/ContextPanel.tsx b/packages/web/src/islands/ContextPanel.tsx
index de5c1aeeb7..ef1d4afc82 100644
--- a/packages/web/src/islands/ContextPanel.tsx
+++ b/packages/web/src/islands/ContextPanel.tsx
@@ -19,6 +19,7 @@ import {
navigateToSpaceSessions,
navigateToSpaceGoals,
navigateToSpaceForge,
+ navigateToSpaceMemories,
navigateToSpaceTasks,
} from '../lib/router.ts';
import { borderColors } from '../lib/design-tokens.ts';
@@ -209,6 +210,9 @@ export function ContextPanel() {
case 'goals':
navigateToSpaceGoals(spaceId);
break;
+ case 'memories':
+ navigateToSpaceMemories(spaceId);
+ break;
case 'forge':
navigateToSpaceForge(spaceId);
break;
diff --git a/packages/web/src/lib/__tests__/memory-store.test.ts b/packages/web/src/lib/__tests__/memory-store.test.ts
index 9e45545205..ddc013855c 100644
--- a/packages/web/src/lib/__tests__/memory-store.test.ts
+++ b/packages/web/src/lib/__tests__/memory-store.test.ts
@@ -103,4 +103,34 @@ describe('memoryStore', () => {
await memoryStore.attach('space-1');
expect(memoryStore.hasMore.value).toBe(false);
});
+
+ it('exists reports backend presence read-only', async () => {
+ let readPayload: Record | undefined;
+ mockRequest.mockImplementation(async (method: string, params: Record = {}) => {
+ if (method === 'agentMemory.list') return [];
+ if (method === 'agentMemory.read') {
+ readPayload = params;
+ return makeMemory('alpha');
+ }
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+
+ const present = await memoryStore.exists('alpha');
+ expect(present).toBe(true);
+ expect(readPayload?.recordAccess).toBe(false);
+ });
+
+ it('exists returns false when absent or on transport error', async () => {
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.list') return [];
+ if (method === 'agentMemory.read') return null;
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+ expect(await memoryStore.exists('missing')).toBe(false);
+
+ mockRequest.mockRejectedValue(new Error('network'));
+ expect(await memoryStore.exists('alpha')).toBe(false);
+ });
});
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
index 757a3a63fd..fdc9bb2e33 100644
--- a/packages/web/src/lib/memory-store.ts
+++ b/packages/web/src/lib/memory-store.ts
@@ -199,6 +199,28 @@ class MemoryStore {
return result.deleted;
}
+ /**
+ * Authoritative existence check for a key. Read-only (`recordAccess: false`)
+ * so it never perturbs telemetry. Best-effort: returns false on transport
+ * error so a failed check never blocks a legitimate create.
+ */
+ async exists(key: string): Promise {
+ const spaceId = this.spaceId;
+ if (!spaceId) return false;
+ try {
+ const hub = await connectionManager.getHub();
+ const entry = await hub.request('agentMemory.read', {
+ spaceId,
+ key,
+ recordAccess: false,
+ });
+ return entry !== null;
+ } catch (err) {
+ logger.warn('memoryStore.exists failed, defaulting to false:', err);
+ return false;
+ }
+ }
+
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
From cefb9242c164b876e61344d470d48b656e3a1af1 Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 16:11:00 -0400
Subject: [PATCH 06/12] fix(memory): App URL-sync memories branch + store race
guards
P1 (the blocker): App.tsx's URL-sync effect re-derives the URL from
view-mode signals but had no 'memories' branch, so expectedPath fell
through to /space/:id and bounced Memories back to Overview (and broke
deep-links/refresh). Extracted the per-view-mode path derivation into a
pure, unit-tested deriveAppExpectedPath helper (lib/app-routing.ts) and
added the memories branch to both expectedPath and the navigation chain.
App-routing test covers every view mode.
P2: clear isLoadingMore unconditionally in loadMore's finally so an
interrupting reload can't stick the Load-more spinner.
P2: guard write/delete optimistic updates with a space-id check after the
await so a mid-mutation space switch can't land the old space's entry in
the new space's list.
Tests: +5 app-routing (all view modes, memories regression), +2 store
(loadMore-interrupt spinner clear, cross-space write contamination).
---
packages/web/src/App.tsx | 61 ++++---------
.../web/src/lib/__tests__/app-routing.test.ts | 75 ++++++++++++++++
.../src/lib/__tests__/memory-store.test.ts | 66 ++++++++++++++
packages/web/src/lib/app-routing.ts | 87 +++++++++++++++++++
packages/web/src/lib/memory-store.ts | 14 ++-
5 files changed, 255 insertions(+), 48 deletions(-)
create mode 100644 packages/web/src/lib/__tests__/app-routing.test.ts
create mode 100644 packages/web/src/lib/app-routing.ts
diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx
index 2198b62b86..ea78bec038 100644
--- a/packages/web/src/App.tsx
+++ b/packages/web/src/App.tsx
@@ -39,23 +39,15 @@ import {
navigateToSpaceConfigure,
navigateToSpaceSessions,
navigateToSpaceGoals,
+ navigateToSpaceMemories,
navigateToSpaceForge,
navigateToSpaceTasks,
navigateToSpaceAgent,
navigateToSpaceSession,
navigateToSpaceTask,
navigateToSettings,
- createSessionPath,
- createSpacePath,
- createSpaceConfigurePath,
- createSpaceSessionsPath,
- createSpaceGoalsPath,
- createSpaceForgePath,
- createSpaceTasksPath,
- createSpaceAgentPath,
- createSpaceSessionPath,
- createSpaceTaskPath,
} from './lib/router.ts';
+import { deriveAppExpectedPath } from './lib/app-routing.ts';
export function App() {
// Set --safe-height CSS custom property on iPad Safari for correct viewport sizing
@@ -135,41 +127,18 @@ export function App() {
const spaceTaskViewTab = currentSpaceTaskViewTabSignal.value;
const navSection = navSectionSignal.value;
const currentPath = window.location.pathname;
- const expectedPath = sessionId
- ? createSessionPath(sessionId)
- : spaceTaskId && spaceId
- ? createSpaceTaskPath(
- spaceId,
- spaceTaskId,
- spaceTaskViewTab !== 'thread' ? spaceTaskViewTab : undefined
- )
- : spaceId && spaceViewMode === 'agents'
- ? createSpaceAgentPath(spaceId, spaceAgentHandle ?? undefined)
- : spaceSessionId && spaceId
- ? createSpaceSessionPath(spaceId, spaceSessionId)
- : spaceId && spaceViewMode === 'sessions'
- ? createSpaceSessionsPath(spaceId)
- : spaceId && spaceViewMode === 'goals'
- ? createSpaceGoalsPath(spaceId)
- : spaceId && spaceViewMode === 'forge'
- ? createSpaceForgePath(spaceId)
- : spaceId && spaceViewMode === 'tasks'
- ? createSpaceTasksPath(
- spaceId,
- spaceTasksFilterTab !== 'active' ? spaceTasksFilterTab : undefined
- )
- : spaceId && spaceViewMode === 'configure'
- ? createSpaceConfigurePath(
- spaceId,
- spaceConfigureTab !== 'agents' ? spaceConfigureTab : undefined
- )
- : spaceId
- ? createSpacePath(spaceId)
- : navSection === 'chats'
- ? '/sessions'
- : navSection === 'settings'
- ? '/settings'
- : '/spaces';
+ const expectedPath = deriveAppExpectedPath({
+ sessionId,
+ spaceId,
+ spaceSessionId,
+ spaceTaskId,
+ spaceAgentHandle,
+ spaceViewMode,
+ spaceConfigureTab,
+ spaceTasksFilterTab,
+ spaceTaskViewTab,
+ navSection,
+ });
// Only update URL if it's out of sync
// This prevents unnecessary history updates and loops
@@ -191,6 +160,8 @@ export function App() {
navigateToSpaceSessions(spaceId, true);
} else if (spaceId && spaceViewMode === 'goals') {
navigateToSpaceGoals(spaceId, true);
+ } else if (spaceId && spaceViewMode === 'memories') {
+ navigateToSpaceMemories(spaceId, true);
} else if (spaceId && spaceViewMode === 'forge') {
navigateToSpaceForge(spaceId, true);
} else if (spaceId && spaceViewMode === 'tasks') {
diff --git a/packages/web/src/lib/__tests__/app-routing.test.ts b/packages/web/src/lib/__tests__/app-routing.test.ts
new file mode 100644
index 0000000000..7896b4d2bc
--- /dev/null
+++ b/packages/web/src/lib/__tests__/app-routing.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest';
+import type { SpaceViewMode } from '../signals';
+import { deriveAppExpectedPath, type AppRoutingState } from '../app-routing';
+
+const base: AppRoutingState = {
+ sessionId: null,
+ spaceId: 's1',
+ spaceSessionId: null,
+ spaceTaskId: null,
+ spaceAgentHandle: null,
+ spaceViewMode: 'overview',
+ spaceConfigureTab: 'agents',
+ spaceTasksFilterTab: 'active',
+ spaceTaskViewTab: 'thread',
+ navSection: 'spaces',
+};
+
+describe('deriveAppExpectedPath', () => {
+ it('derives the memories path (regression: used to fall through to Overview)', () => {
+ // Before the fix this returned /space/s1 (Overview), which made the App
+ // URL-sync effect bounce Memories back to Overview.
+ expect(deriveAppExpectedPath({ ...base, spaceViewMode: 'memories' })).toBe(
+ '/space/s1/memories'
+ );
+ });
+
+ it('derives a distinct path for every space view mode', () => {
+ const cases: Array<[SpaceViewMode, string]> = [
+ ['overview', '/space/s1'],
+ ['goals', '/space/s1/goals'],
+ ['memories', '/space/s1/memories'],
+ ['forge', '/space/s1/forge'],
+ ['tasks', '/space/s1/tasks'],
+ ['sessions', '/space/s1/sessions'],
+ ['agents', '/space/s1/agents'],
+ ['configure', '/space/s1/configure'],
+ ];
+ for (const [mode, path] of cases) {
+ expect(deriveAppExpectedPath({ ...base, spaceViewMode: mode })).toBe(path);
+ }
+ });
+
+ it('honors non-default tasks filter and configure tab', () => {
+ expect(
+ deriveAppExpectedPath({ ...base, spaceViewMode: 'tasks', spaceTasksFilterTab: 'draft' })
+ ).toBe('/space/s1/tasks/draft');
+ expect(
+ deriveAppExpectedPath({ ...base, spaceViewMode: 'configure', spaceConfigureTab: 'workflows' })
+ ).toBe('/space/s1/configure/workflows');
+ });
+
+ it('prioritizes a selected session/task over the space view mode', () => {
+ expect(
+ deriveAppExpectedPath({
+ ...base,
+ spaceViewMode: 'memories',
+ spaceTaskId: 't-1',
+ spaceTaskViewTab: 'thread',
+ })
+ ).toBe('/space/s1/task/t-1');
+ expect(deriveAppExpectedPath({ ...base, spaceViewMode: 'memories', sessionId: 'sess-1' })).toBe(
+ '/session/sess-1'
+ );
+ });
+
+ it('falls back to nav-section roots when no space is selected', () => {
+ expect(deriveAppExpectedPath({ ...base, spaceId: null, navSection: 'chats' })).toBe(
+ '/sessions'
+ );
+ expect(deriveAppExpectedPath({ ...base, spaceId: null, navSection: 'settings' })).toBe(
+ '/settings'
+ );
+ expect(deriveAppExpectedPath({ ...base, spaceId: null, navSection: 'spaces' })).toBe('/spaces');
+ });
+});
diff --git a/packages/web/src/lib/__tests__/memory-store.test.ts b/packages/web/src/lib/__tests__/memory-store.test.ts
index ddc013855c..ccd9c5d8b0 100644
--- a/packages/web/src/lib/__tests__/memory-store.test.ts
+++ b/packages/web/src/lib/__tests__/memory-store.test.ts
@@ -133,4 +133,70 @@ describe('memoryStore', () => {
mockRequest.mockRejectedValue(new Error('network'));
expect(await memoryStore.exists('alpha')).toBe(false);
});
+
+ it('clears the load-more spinner when a reload interrupts it', async () => {
+ mockRequest.mockResolvedValueOnce(Array.from({ length: 100 }, (_, i) => makeMemory(`a${i}`)));
+ await memoryStore.attach('space-1');
+ expect(memoryStore.hasMore.value).toBe(true);
+
+ // loadMore's fetch is deferred so a reload can interrupt mid-flight.
+ let releaseLoadMore!: (rows: AgentMemoryEntry[]) => void;
+ let loadMoreFetchStarted = false;
+ mockRequest.mockImplementation((method: string) => {
+ if (method !== 'agentMemory.list') throw new Error(`unexpected ${method}`);
+ if (!loadMoreFetchStarted) {
+ loadMoreFetchStarted = true;
+ return new Promise((resolve) => {
+ releaseLoadMore = resolve;
+ });
+ }
+ return Promise.resolve([]); // the interrupting reload resolves immediately
+ });
+
+ const loadMoreP = memoryStore.loadMore();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(memoryStore.isLoadingMore.value).toBe(true);
+
+ await memoryStore.reload(); // advances loadGeneration while loadMore is pending
+ expect(memoryStore.isLoading.value).toBe(false);
+
+ releaseLoadMore([makeMemory('k100')]);
+ await loadMoreP;
+ // Spinner cleared despite the interrupt (was stuck before the fix).
+ expect(memoryStore.isLoadingMore.value).toBe(false);
+ });
+
+ it('does not contaminate the new space when a write completes after a switch', async () => {
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.list') return [makeMemory('alpha')];
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+
+ // Defer the write RPC so we can switch space before it resolves.
+ let releaseWrite!: (entry: AgentMemoryEntry) => void;
+ mockRequest.mockImplementation((method: string) => {
+ if (method === 'agentMemory.write') {
+ return new Promise((resolve) => {
+ releaseWrite = resolve;
+ });
+ }
+ if (method === 'agentMemory.list') return Promise.resolve([makeMemory('gamma')]);
+ throw new Error(`unexpected ${method}`);
+ });
+
+ const writeP = memoryStore.write({ key: 'beta', content: 'x' });
+ await Promise.resolve();
+ await Promise.resolve();
+
+ await memoryStore.attach('space-2'); // switch space mid-write
+ expect(memoryStore.memories.value.map((m) => m.key)).toEqual(['gamma']);
+
+ releaseWrite(makeMemory('beta'));
+ const entry = await writeP;
+ expect(entry.key).toBe('beta');
+ // beta (space-1) must not appear in space-2's list.
+ expect(memoryStore.memories.value.map((m) => m.key)).toEqual(['gamma']);
+ });
});
diff --git a/packages/web/src/lib/app-routing.ts b/packages/web/src/lib/app-routing.ts
new file mode 100644
index 0000000000..80ea4e332a
--- /dev/null
+++ b/packages/web/src/lib/app-routing.ts
@@ -0,0 +1,87 @@
+/**
+ * Pure derivation of the URL the app expects for the current navigation state.
+ *
+ * Extracted from App.tsx's URL-sync effect so the per-view-mode branching is
+ * unit-testable without mounting the whole app (the component tests mock the
+ * store, which is how a missing view-mode branch previously slipped through).
+ *
+ * Keep this in sync with the navigation dispatch in App.tsx: every view mode
+ * that produces an `expectedPath` here must also have a matching navigate
+ * branch there, and vice versa.
+ */
+
+import {
+ createSessionPath,
+ createSpacePath,
+ createSpaceAgentPath,
+ createSpaceConfigurePath,
+ createSpaceForgePath,
+ createSpaceGoalsPath,
+ createSpaceMemoriesPath,
+ createSpaceSessionPath,
+ createSpaceSessionsPath,
+ createSpaceTaskPath,
+ createSpaceTasksPath,
+} from './router';
+import type { SpaceViewMode } from './signals';
+
+export interface AppRoutingState {
+ sessionId: string | null;
+ spaceId: string | null;
+ spaceSessionId: string | null;
+ spaceTaskId: string | null;
+ spaceAgentHandle: string | null;
+ spaceViewMode: SpaceViewMode;
+ spaceConfigureTab: string;
+ spaceTasksFilterTab: string;
+ spaceTaskViewTab: string;
+ navSection: string;
+}
+
+export function deriveAppExpectedPath(state: AppRoutingState): string {
+ const {
+ sessionId,
+ spaceId,
+ spaceSessionId,
+ spaceTaskId,
+ spaceAgentHandle,
+ spaceViewMode,
+ spaceConfigureTab,
+ spaceTasksFilterTab,
+ spaceTaskViewTab,
+ navSection,
+ } = state;
+
+ if (sessionId) return createSessionPath(sessionId);
+ if (spaceTaskId && spaceId) {
+ return createSpaceTaskPath(
+ spaceId,
+ spaceTaskId,
+ spaceTaskViewTab !== 'thread' ? spaceTaskViewTab : undefined
+ );
+ }
+ if (spaceId && spaceViewMode === 'agents') {
+ return createSpaceAgentPath(spaceId, spaceAgentHandle ?? undefined);
+ }
+ if (spaceSessionId && spaceId) return createSpaceSessionPath(spaceId, spaceSessionId);
+ if (spaceId && spaceViewMode === 'sessions') return createSpaceSessionsPath(spaceId);
+ if (spaceId && spaceViewMode === 'goals') return createSpaceGoalsPath(spaceId);
+ if (spaceId && spaceViewMode === 'memories') return createSpaceMemoriesPath(spaceId);
+ if (spaceId && spaceViewMode === 'forge') return createSpaceForgePath(spaceId);
+ if (spaceId && spaceViewMode === 'tasks') {
+ return createSpaceTasksPath(
+ spaceId,
+ spaceTasksFilterTab !== 'active' ? spaceTasksFilterTab : undefined
+ );
+ }
+ if (spaceId && spaceViewMode === 'configure') {
+ return createSpaceConfigurePath(
+ spaceId,
+ spaceConfigureTab !== 'agents' ? spaceConfigureTab : undefined
+ );
+ }
+ if (spaceId) return createSpacePath(spaceId);
+ if (navSection === 'chats') return '/sessions';
+ if (navSection === 'settings') return '/settings';
+ return '/spaces';
+}
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
index fdc9bb2e33..c6d998dfa2 100644
--- a/packages/web/src/lib/memory-store.ts
+++ b/packages/web/src/lib/memory-store.ts
@@ -147,9 +147,10 @@ class MemoryStore {
this.error.value = err instanceof Error ? err.message : 'Failed to load more memories';
logger.error('Failed to load more memories:', err);
} finally {
- if (generation === this.loadGeneration) {
- this.isLoadingMore.value = false;
- }
+ // Always clear the load-more spinner: if a reload interrupted this fetch
+ // (generation advanced), the result is discarded above, but leaving
+ // isLoadingMore pinned would stick the button and block future loadMore.
+ this.isLoadingMore.value = false;
}
}
@@ -179,6 +180,10 @@ class MemoryStore {
content: params.content,
tags: params.tags,
});
+ // If the space switched while the write was in flight, don't contaminate the
+ // new space's view with the old space's entry. The write persisted
+ // server-side and will be visible when the user returns to that space.
+ if (this.spaceId !== spaceId) return entry;
// Optimistically reflect the write even if the refresh below fails.
this.upsertEntry(entry);
await this.refreshBestEffort();
@@ -194,6 +199,9 @@ class MemoryStore {
spaceId,
key,
});
+ // Guard against a mid-delete space switch landing the removal in the wrong
+ // space's view (see write()).
+ if (this.spaceId !== spaceId) return result.deleted;
if (result.deleted) this.removeEntry(key);
await this.refreshBestEffort();
return result.deleted;
From 5b80f889d61ca27a3ef4374fd95ffbb32f0c860e Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 16:17:10 -0400
Subject: [PATCH 07/12] fix(memory): atomic create RPC (insert-only) for New
Memory
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes the duplicate-key TOCTOU flagged by Codex (P1/P2): create mode
now uses a server-side insert-only RPC (agentMemory.create → repo.create
with ON CONFLICT DO NOTHING) that fails atomically when the key exists,
instead of relying on the async preflight read + write()'s upsert. A
colliding create now surfaces 'already exists' rather than silently
overwriting. Edit mode still upserts via write().
- daemon: AgentMemoryRepository.create (throws on conflict);
agentMemory.create handler (provenance forced null, like write).
- web: memory-store.create (same cross-space guard as write); editor
create path calls create, edit path calls write.
Tests: +1 daemon repo (create inserts / rejects dup), +1 daemon handler
(create passthrough), +2 store (create payload + conflict propagation),
+1 component (atomic create conflict shows error).
---
.../lib/rpc-handlers/agent-memory-handlers.ts | 14 ++++++
.../repositories/agent-memory-repository.ts | 47 +++++++++++++++++++
.../agent-memory-handlers.test.ts | 23 +++++++++
.../agent-memory-repository.test.ts | 15 ++++++
.../components/space/SpaceMemoryEditor.tsx | 10 ++--
.../space/__tests__/SpaceMemories.test.tsx | 23 ++++++++-
.../src/lib/__tests__/memory-store.test.ts | 31 ++++++++++++
packages/web/src/lib/memory-store.ts | 26 ++++++++++
8 files changed, 183 insertions(+), 6 deletions(-)
diff --git a/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts b/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
index ffe573acc2..f554a4693f 100644
--- a/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
+++ b/packages/daemon/src/lib/rpc-handlers/agent-memory-handlers.ts
@@ -26,6 +26,20 @@ export function setupAgentMemoryHandlers(
});
});
+ // Atomic insert-only create: fails when the (spaceId, key) already exists, so
+ // the management UI's "New Memory" flow can never silently overwrite. Edits
+ // go through agentMemory.write (upsert) instead.
+ messageHub.onRequest('agentMemory.create', async (payload: unknown) => {
+ const request = parseSpaceScopedRequest(payload);
+ return deps.memoryRepo.create({
+ spaceId: request.spaceId,
+ key: readRequiredString(payload, 'key'),
+ content: readRequiredString(payload, 'content'),
+ tags: readOptionalStringArray(payload, 'tags'),
+ createdBySession: null,
+ });
+ });
+
messageHub.onRequest('agentMemory.search', async (payload: unknown) => {
const request = parseSpaceScopedRequest(payload);
return deps.memoryRepo.search(
diff --git a/packages/daemon/src/storage/repositories/agent-memory-repository.ts b/packages/daemon/src/storage/repositories/agent-memory-repository.ts
index 7d7a0c6eba..ae62eccc75 100644
--- a/packages/daemon/src/storage/repositories/agent-memory-repository.ts
+++ b/packages/daemon/src/storage/repositories/agent-memory-repository.ts
@@ -137,6 +137,53 @@ export class AgentMemoryRepository {
return rowToEntry(row);
}
+ /**
+ * Atomically create a new memory. Unlike write() (which upserts on
+ * (spaceId, key)), this fails when a memory with the same key already exists,
+ * so a create can never silently overwrite an existing entry. Used by the
+ * management UI's "New Memory" flow; agent-authored writes still use write().
+ */
+ create(params: {
+ spaceId: string;
+ key: string;
+ content: string;
+ tags?: string[];
+ createdBySession?: string | null;
+ }): AgentMemoryEntry {
+ const key = normalizeKey(params.key);
+ const content = normalizeContent(params.content);
+ const tags = normalizeTags(params.tags ?? []);
+ const now = Date.now();
+ const embeddingToken = crypto.randomUUID();
+
+ const row = this.db
+ .prepare(
+ `INSERT INTO space_agent_memory
+ (key, space_id, content, tags, created_by_session, created_at, updated_at, access_count, last_accessed_at, embedding_status, embedding_model, embedding_updated_at, embedding_error, embedding_revision, embedding_token)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, 'pending', NULL, NULL, NULL, 1, ?)
+ ON CONFLICT(space_id, key) DO NOTHING
+ RETURNING *`
+ )
+ .get(
+ key,
+ params.spaceId,
+ content,
+ serializeTags(tags),
+ params.createdBySession ?? null,
+ now,
+ now,
+ embeddingToken
+ ) as AgentMemoryRow | undefined;
+
+ if (!row) {
+ throw new Error(`A memory with the key "${key}" already exists in this space.`);
+ }
+
+ this.updateEmbedding(row);
+ this.reactiveDb?.notifyChange('space_agent_memory');
+ return rowToEntry(row);
+ }
+
read(
spaceId: string,
key: string,
diff --git a/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts b/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts
index 65cbae2566..66ff00278d 100644
--- a/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts
+++ b/packages/daemon/tests/unit/2-handlers/rpc-handlers/agent-memory-handlers.test.ts
@@ -61,6 +61,29 @@ describe('agent memory RPC handlers', () => {
expect(writes[0]?.tags).toBeUndefined();
});
+ test('create delegates to the repository insert-only create', async () => {
+ const { messageHub, handlers } = createMessageHubStub();
+ const creates: Array> = [];
+ setupAgentMemoryHandlers(messageHub as never, {
+ memoryRepo: {
+ create: (params: Record) => {
+ creates.push(params);
+ return params;
+ },
+ } as never,
+ });
+
+ await handlers.get('agentMemory.create')?.({
+ spaceId: 'space-a',
+ key: 'k',
+ content: 'c',
+ tags: ['t'],
+ });
+
+ expect(creates[0]).toMatchObject({ spaceId: 'space-a', key: 'k', content: 'c', tags: ['t'] });
+ expect(creates[0]?.createdBySession).toBeNull();
+ });
+
test('write ignores caller-supplied createdBySession', async () => {
const { messageHub, handlers } = createMessageHubStub();
const writes: Array> = [];
diff --git a/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts b/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts
index 7058402bf2..fa86e39fe6 100644
--- a/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts
+++ b/packages/daemon/tests/unit/4-space-storage/agent-memory-repository.test.ts
@@ -175,6 +175,21 @@ describe('AgentMemoryRepository', () => {
expect(read?.createdBySession).toBe('session-1');
});
+ test('create atomically inserts and rejects on a duplicate key', () => {
+ repo.write({ spaceId: 'space-a', key: 'dup', content: 'first' });
+
+ // Same (spaceId, key) must not silently overwrite.
+ expect(() => repo.create({ spaceId: 'space-a', key: 'dup', content: 'second' })).toThrow(
+ 'already exists'
+ );
+ expect(repo.read('space-a', 'dup', { recordAccess: false })?.content).toBe('first');
+
+ // A genuinely new key creates normally.
+ const created = repo.create({ spaceId: 'space-a', key: 'fresh', content: 'new' });
+ expect(created.key).toBe('fresh');
+ expect(repo.read('space-a', 'fresh', { recordAccess: false })?.content).toBe('new');
+ });
+
test('filtered list does not record access when recordAccess is false', async () => {
repo.write({
spaceId: 'space-a',
diff --git a/packages/web/src/components/space/SpaceMemoryEditor.tsx b/packages/web/src/components/space/SpaceMemoryEditor.tsx
index 081c6e273c..b5049b641d 100644
--- a/packages/web/src/components/space/SpaceMemoryEditor.tsx
+++ b/packages/web/src/components/space/SpaceMemoryEditor.tsx
@@ -110,11 +110,11 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
setSaving(true);
try {
- const entry = await memoryStore.write({
- key: trimmedKey,
- content: trimmedContent,
- tags,
- });
+ // Create mode uses the atomic insert-only RPC (fails on key conflict);
+ // edit mode upserts via write().
+ const entry = isEditing
+ ? await memoryStore.write({ key: trimmedKey, content: trimmedContent, tags })
+ : await memoryStore.create({ key: trimmedKey, content: trimmedContent, tags });
toast.success(`Memory "${entry.key}" saved`);
onClose();
} catch (err) {
diff --git a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
index 013d1723aa..b8cb792eef 100644
--- a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
+++ b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
@@ -18,6 +18,7 @@ const mockDeleteMemory = vi.fn();
const mockReload = vi.fn();
const mockLoadMore = vi.fn();
const mockExists = vi.fn();
+const mockCreate = vi.fn();
vi.mock('../../../lib/memory-store', () => ({
get memoryStore() {
@@ -33,6 +34,7 @@ vi.mock('../../../lib/memory-store', () => ({
detach: mockDetach,
search: mockSearch,
write: mockWrite,
+ create: mockCreate,
deleteMemory: mockDeleteMemory,
reload: mockReload,
loadMore: mockLoadMore,
@@ -88,9 +90,11 @@ describe('SpaceMemories', () => {
mockReload.mockReset();
mockLoadMore.mockReset();
mockExists.mockReset();
+ mockCreate.mockReset();
mockAttach.mockResolvedValue(undefined);
mockLoadMore.mockResolvedValue(undefined);
mockExists.mockResolvedValue(false);
+ mockCreate.mockResolvedValue(makeMemory('whatever'));
mockSearch.mockResolvedValue(undefined);
mockReload.mockResolvedValue(undefined);
mockWrite.mockResolvedValue(makeMemory('whatever'));
@@ -206,12 +210,29 @@ describe('SpaceMemories', () => {
fireEvent.click(screen.getByTestId('memory-save-button'));
await waitFor(() =>
- expect(mockWrite).toHaveBeenCalledWith({
+ expect(mockCreate).toHaveBeenCalledWith({
key: 'new-key',
content: 'A useful fact.',
tags: ['project', 'feedback'],
})
);
+ expect(mockWrite).not.toHaveBeenCalled();
+ });
+
+ it('surfaces an atomic create conflict as an error', async () => {
+ // Key passes the local preflight but the backend rejects the insert.
+ mockCreate.mockRejectedValue(new Error('A memory with the key "sneaky" already exists.'));
+
+ render( );
+ fireEvent.click(screen.getByTestId('memory-create-button'));
+ const keyInput = await screen.findByTestId('memory-key-input');
+ fireEvent.input(keyInput, { target: { value: 'sneaky' } });
+ fireEvent.input(screen.getByTestId('memory-content-input'), { target: { value: 'Body.' } });
+ fireEvent.click(screen.getByTestId('memory-save-button'));
+
+ await waitFor(() =>
+ expect(screen.getByTestId('memory-editor-error').textContent).toContain('already exists')
+ );
});
it('blocks create when the key already exists', async () => {
diff --git a/packages/web/src/lib/__tests__/memory-store.test.ts b/packages/web/src/lib/__tests__/memory-store.test.ts
index ccd9c5d8b0..5370c818ed 100644
--- a/packages/web/src/lib/__tests__/memory-store.test.ts
+++ b/packages/web/src/lib/__tests__/memory-store.test.ts
@@ -61,6 +61,37 @@ describe('memoryStore', () => {
expect(memoryStore.error.value).toBeNull();
});
+ it('create uses the atomic insert RPC', async () => {
+ let createPayload: Record | undefined;
+ mockRequest.mockImplementation(async (method: string, params: Record = {}) => {
+ if (method === 'agentMemory.list') return [];
+ if (method === 'agentMemory.create') {
+ createPayload = params;
+ return makeMemory('beta');
+ }
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+
+ const entry = await memoryStore.create({ key: 'beta', content: 'x', tags: ['t'] });
+ expect(entry.key).toBe('beta');
+ expect(createPayload).toMatchObject({ key: 'beta', content: 'x', tags: ['t'] });
+ });
+
+ it('create propagates a conflict', async () => {
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.list') return [];
+ if (method === 'agentMemory.create') {
+ throw new Error('A memory with the key "beta" already exists in this space.');
+ }
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+ await expect(memoryStore.create({ key: 'beta', content: 'x' })).rejects.toThrow(
+ 'already exists'
+ );
+ });
+
it('optimistically removes a deleted memory', async () => {
mockRequest.mockImplementation(async (method: string) => {
if (method === 'agentMemory.list') return [makeMemory('alpha'), makeMemory('beta')];
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
index c6d998dfa2..bc2f3a6888 100644
--- a/packages/web/src/lib/memory-store.ts
+++ b/packages/web/src/lib/memory-store.ts
@@ -190,6 +190,32 @@ class MemoryStore {
return entry;
}
+ /**
+ * Atomically create a new memory. The daemon inserts with ON CONFLICT DO
+ * NOTHING and rejects when the key already exists, so this never silently
+ * overwrites an existing entry (unlike write()'s upsert). Use for the
+ * "New Memory" flow; edits should use write().
+ */
+ async create(params: {
+ key: string;
+ content: string;
+ tags?: string[];
+ }): Promise {
+ const spaceId = this.spaceId;
+ if (!spaceId) throw new Error('No space selected.');
+ const hub = await connectionManager.getHub();
+ const entry = await hub.request('agentMemory.create', {
+ spaceId,
+ key: params.key,
+ content: params.content,
+ tags: params.tags,
+ });
+ if (this.spaceId !== spaceId) return entry;
+ this.upsertEntry(entry);
+ await this.refreshBestEffort();
+ return entry;
+ }
+
/** Delete a memory by key. Returns whether a row was actually deleted. */
async deleteMemory(key: string): Promise {
const spaceId = this.spaceId;
From 8da14e7e1787ec4e80933103bd393d9facfea6e2 Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 16:23:35 -0400
Subject: [PATCH 08/12] fix(memory): pagination/load-more race hardening +
delete-modal guard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes three more store/UI races flagged on the atomic-create push:
- Guard delete-modal dismissal while a deletion is pending (backdrop/
Escape could close the dialog mid-RPC and race a newly-opened delete).
- reload() now resets hasMore at the start, so a failed/in-flight reload
can't leave stale pagination enabled — Load-more can't append rows from
a previous query/space against a failed replacement page.
- loadMore's isLoadingMore clear is now ownership-tracked (loadMoreGen),
invalidated by detach and by each new loadMore: a stale loadMore
settling after a space switch can no longer clear the new space's
spinner, while a same-space reload interrupt still clears (no stuck
button).
Tests: +2 store (failed-reload hasMore reset, stale loadMore doesn't
clobber newer spinner).
---
.../src/components/space/SpaceMemories.tsx | 3 +
.../src/lib/__tests__/memory-store.test.ts | 58 +++++++++++++++++++
packages/web/src/lib/memory-store.ts | 26 +++++++--
3 files changed, 83 insertions(+), 4 deletions(-)
diff --git a/packages/web/src/components/space/SpaceMemories.tsx b/packages/web/src/components/space/SpaceMemories.tsx
index 8da84960c0..3828d388bc 100644
--- a/packages/web/src/components/space/SpaceMemories.tsx
+++ b/packages/web/src/components/space/SpaceMemories.tsx
@@ -371,6 +371,9 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
{
+ // Block backdrop/Escape dismissal while the delete RPC is in flight
+ // so it can't race a newly-opened delete confirmation.
+ if (deleting) return;
setDeletingMemory(null);
setDeleteError(null);
}}
diff --git a/packages/web/src/lib/__tests__/memory-store.test.ts b/packages/web/src/lib/__tests__/memory-store.test.ts
index 5370c818ed..a5abf71ea9 100644
--- a/packages/web/src/lib/__tests__/memory-store.test.ts
+++ b/packages/web/src/lib/__tests__/memory-store.test.ts
@@ -165,6 +165,64 @@ describe('memoryStore', () => {
expect(await memoryStore.exists('alpha')).toBe(false);
});
+ it('clears hasMore when a reload fails so stale rows cannot be extended', async () => {
+ mockRequest.mockResolvedValueOnce(Array.from({ length: 100 }, (_, i) => makeMemory(`a${i}`)));
+ await memoryStore.attach('space-1');
+ expect(memoryStore.hasMore.value).toBe(true);
+
+ // A new search reload fails.
+ mockRequest.mockRejectedValue(new Error('boom'));
+ await expect(memoryStore.search('anything')).rejects.toThrow('boom');
+ // hasMore reset, so Load-more can't append stale rows for the new query.
+ expect(memoryStore.hasMore.value).toBe(false);
+ });
+
+ it('a stale loadMore does not clear a newer space loadMore spinner', async () => {
+ mockRequest.mockResolvedValueOnce(Array.from({ length: 100 }, (_, i) => makeMemory(`a${i}`)));
+ await memoryStore.attach('space-1');
+
+ // space-1 loadMore A — deferred so it stays pending across the switch.
+ let releaseA!: (rows: AgentMemoryEntry[]) => void;
+ mockRequest.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseA = resolve;
+ })
+ );
+ const loadMoreA = memoryStore.loadMore();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(memoryStore.isLoadingMore.value).toBe(true);
+
+ // Switch to space-2 (detach invalidates A) and load a full page.
+ memoryStore.detach();
+ mockRequest.mockResolvedValueOnce(Array.from({ length: 100 }, (_, i) => makeMemory(`b${i}`)));
+ await memoryStore.attach('space-2');
+ expect(memoryStore.hasMore.value).toBe(true);
+
+ // space-2 loadMore B — deferred.
+ let releaseB!: (rows: AgentMemoryEntry[]) => void;
+ mockRequest.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseB = resolve;
+ })
+ );
+ const loadMoreB = memoryStore.loadMore();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(memoryStore.isLoadingMore.value).toBe(true);
+
+ // Stale A settles — must NOT clear B's spinner.
+ releaseA([makeMemory('a100')]);
+ await loadMoreA;
+ expect(memoryStore.isLoadingMore.value).toBe(true);
+
+ releaseB([makeMemory('b100')]);
+ await loadMoreB;
+ expect(memoryStore.isLoadingMore.value).toBe(false);
+ });
+
it('clears the load-more spinner when a reload interrupts it', async () => {
mockRequest.mockResolvedValueOnce(Array.from({ length: 100 }, (_, i) => makeMemory(`a${i}`)));
await memoryStore.attach('space-1');
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
index bc2f3a6888..9999c13c82 100644
--- a/packages/web/src/lib/memory-store.ts
+++ b/packages/web/src/lib/memory-store.ts
@@ -67,11 +67,20 @@ class MemoryStore {
*/
private loadGeneration = 0;
+ /**
+ * Ownership token for the active load-more request. Incremented when a new
+ * loadMore starts and on detach; a loadMore only clears `isLoadingMore` if it
+ * is still the active owner, so a stale request settling after a space switch
+ * cannot clear the new space's spinner.
+ */
+ private loadMoreGen = 0;
+
/** Reset all signals and unbind from the current space. */
detach(): void {
this.spaceId = null;
this.offset = 0;
this.loadGeneration++;
+ this.loadMoreGen++;
this.memories.value = [];
this.query.value = '';
this.hasMore.value = false;
@@ -103,6 +112,10 @@ class MemoryStore {
const generation = ++this.loadGeneration;
this.isLoading.value = true;
this.error.value = null;
+ // Drop stale pagination until the replacement page lands: if this reload
+ // fails (or is in flight) we don't want Load-more enabled against rows
+ // from a previous query/space.
+ this.hasMore.value = false;
try {
const rows = await this.fetchPage(spaceId, 0);
if (generation !== this.loadGeneration) return;
@@ -131,6 +144,7 @@ class MemoryStore {
if (!spaceId || !this.hasMore.value || this.isLoadingMore.value) return;
const offset = this.offset + PAGE_SIZE;
const generation = ++this.loadGeneration;
+ const owner = ++this.loadMoreGen;
this.isLoadingMore.value = true;
try {
const rows = await this.fetchPage(spaceId, offset);
@@ -147,10 +161,14 @@ class MemoryStore {
this.error.value = err instanceof Error ? err.message : 'Failed to load more memories';
logger.error('Failed to load more memories:', err);
} finally {
- // Always clear the load-more spinner: if a reload interrupted this fetch
- // (generation advanced), the result is discarded above, but leaving
- // isLoadingMore pinned would stick the button and block future loadMore.
- this.isLoadingMore.value = false;
+ // Only clear the spinner if this request is still the active load-more
+ // owner. A space switch (detach) or a newer loadMore bumps loadMoreGen,
+ // so a stale request settling late won't clear the new space's spinner
+ // — but a same-space reload that interrupted this still lets us clear
+ // (loadMoreGen unchanged) so the button never sticks.
+ if (owner === this.loadMoreGen) {
+ this.isLoadingMore.value = false;
+ }
}
}
From d4466b2149ec82b29cf753e6e1e8a287b2c521b9 Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 16:56:19 -0400
Subject: [PATCH 09/12] fix(memory): mutation-refresh failure-path matrix
(review round 4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address the optimistic-mutation × best-effort-refresh × active-search
failure states — refreshBestEffort now snapshots/restores view state and
reload takes over pagination, so a refresh failure after a mutation no
longer leaks cleared state.
- P2-1: write/create skip the optimistic upsert while a search is active
(a non-matching entry would pollute ranked results + re-sort).
- P2-2: refreshBestEffort only suppresses the refresh error when a page
had already loaded; if the initial load failed, the error stays visible
instead of a permanent spinner.
- P2-3: refreshBestEffort restores hasMore on failure (reload clears it
upfront) so Load-more isn't silently lost after a mutation.
- P2-4: reload() bumps loadMoreGen + clears isLoadingMore when it takes
over, so a loadMore fetch that never settles can't pin the spinner.
- P2-5: editor omits unchanged tags on edit (existing tags may contain
commas that can't round-trip through the comma-split input); only
changed tags are re-parsed and sent.
- P3-6: header shows 'N+' when more pages remain (no backend total).
Tests: +3 store (search-skip-upsert, keep-error-pre-load, restore-hasMore)
and the editor edit test now covers tag preservation + resend-on-change.
---
.../src/components/space/SpaceMemories.tsx | 3 +-
.../components/space/SpaceMemoryEditor.tsx | 28 +++++++----
.../space/__tests__/SpaceMemories.test.tsx | 29 +++++++++--
.../src/lib/__tests__/memory-store.test.ts | 50 +++++++++++++++++++
packages/web/src/lib/memory-store.ts | 40 +++++++++++----
5 files changed, 128 insertions(+), 22 deletions(-)
diff --git a/packages/web/src/components/space/SpaceMemories.tsx b/packages/web/src/components/space/SpaceMemories.tsx
index 3828d388bc..a91af9df61 100644
--- a/packages/web/src/components/space/SpaceMemories.tsx
+++ b/packages/web/src/components/space/SpaceMemories.tsx
@@ -236,7 +236,8 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
- Memories · {memories.length} {searchActive ? 'results' : 'stored'}
+ Memories · {memories.length}
+ {hasMore ? '+' : ''} {searchActive ? 'results' : 'stored'}
Persistent facts, conventions, and decisions this space's agents can recall. Search
diff --git a/packages/web/src/components/space/SpaceMemoryEditor.tsx b/packages/web/src/components/space/SpaceMemoryEditor.tsx
index b5049b641d..d355d8b663 100644
--- a/packages/web/src/components/space/SpaceMemoryEditor.tsx
+++ b/packages/web/src/components/space/SpaceMemoryEditor.tsx
@@ -43,6 +43,9 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
const [key, setKey] = useState(memory?.key ?? '');
const [content, setContent] = useState(memory?.content ?? '');
const [tagsInput, setTagsInput] = useState(memory?.tags.join(', ') ?? '');
+ // Snapshot of the tags field at open, to detect whether the user changed it
+ // (unchanged tags are not resent — see handleSave).
+ const initialTagsInput = memory?.tags.join(', ') ?? '';
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
// Authoritative duplicate flag resolved against the backend, since the
@@ -82,7 +85,6 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
setError(null);
const trimmedContent = content.trim();
- const tags = parseTagsInput(tagsInput);
if (!isEditing && !trimmedKey) {
setError('Key is required.');
@@ -98,14 +100,22 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
setError('Content is required.');
return;
}
- const oversizedTag = tags.find((tag) => tag.length > TAG_MAX_LENGTH);
- if (oversizedTag) {
- setError(`Tags must be ${TAG_MAX_LENGTH} characters or fewer.`);
- return;
- }
- if (tags.length > TAG_MAX_COUNT) {
- setError(`A memory can have at most ${TAG_MAX_COUNT} tags.`);
- return;
+ // Only resend tags when they changed. Existing tags may contain commas
+ // (allowed by the daemon) which can't round-trip through the comma-split
+ // input; resending them unchanged would silently mangle them. When
+ // omitted, the daemon preserves the stored tags.
+ const tagsChanged = tagsInput !== initialTagsInput;
+ const tags = tagsChanged ? parseTagsInput(tagsInput) : undefined;
+ if (tags) {
+ const oversizedTag = tags.find((tag) => tag.length > TAG_MAX_LENGTH);
+ if (oversizedTag) {
+ setError(`Tags must be ${TAG_MAX_LENGTH} characters or fewer.`);
+ return;
+ }
+ if (tags.length > TAG_MAX_COUNT) {
+ setError(`A memory can have at most ${TAG_MAX_COUNT} tags.`);
+ return;
+ }
}
setSaving(true);
diff --git a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
index b8cb792eef..759b782886 100644
--- a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
+++ b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
@@ -302,8 +302,10 @@ describe('SpaceMemories', () => {
expect(mockWrite).not.toHaveBeenCalled();
});
- it('edits an existing memory with a locked key', async () => {
- mockMemories.value = [makeMemory('alpha', { content: 'old body', tags: ['x'] })];
+ it('edits an existing memory with a locked key, preserving unchanged tags', async () => {
+ // Tag 'x,y' contains a comma and can't round-trip through the comma-split
+ // input; an unrelated content edit must NOT resend (and mangle) it.
+ mockMemories.value = [makeMemory('alpha', { content: 'old body', tags: ['x,y'] })];
render( );
fireEvent.click(screen.getByTestId('memory-edit-alpha'));
@@ -319,7 +321,28 @@ describe('SpaceMemories', () => {
expect(mockWrite).toHaveBeenCalledWith({
key: 'alpha',
content: 'updated body',
- tags: ['x'],
+ // tags omitted (unchanged) so the daemon preserves 'x,y'.
+ tags: undefined,
+ })
+ );
+ });
+
+ it('resends parsed tags when the user edits them', async () => {
+ mockMemories.value = [makeMemory('alpha', { content: 'old body', tags: ['old'] })];
+
+ render( );
+ fireEvent.click(screen.getByTestId('memory-edit-alpha'));
+ await screen.findByTestId('memory-key-input');
+ fireEvent.input(screen.getByTestId('memory-tags-input'), {
+ target: { value: 'new1, new2' },
+ });
+ fireEvent.click(screen.getByTestId('memory-save-button'));
+
+ await waitFor(() =>
+ expect(mockWrite).toHaveBeenCalledWith({
+ key: 'alpha',
+ content: 'old body',
+ tags: ['new1', 'new2'],
})
);
});
diff --git a/packages/web/src/lib/__tests__/memory-store.test.ts b/packages/web/src/lib/__tests__/memory-store.test.ts
index a5abf71ea9..bdd9166f00 100644
--- a/packages/web/src/lib/__tests__/memory-store.test.ts
+++ b/packages/web/src/lib/__tests__/memory-store.test.ts
@@ -92,6 +92,56 @@ describe('memoryStore', () => {
);
});
+ it('does not optimistically insert during an active search', async () => {
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.list') return [makeMemory('alpha')];
+ if (method === 'agentMemory.write') return makeMemory('beta', { updatedAt: 9 });
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+ await memoryStore.search('alpha'); // active query
+
+ await memoryStore.write({ key: 'beta', content: 'x' });
+ // beta (which does not match 'alpha') must not pollute the ranked results.
+ expect(memoryStore.memories.value.map((m) => m.key)).toEqual(['alpha']);
+ });
+
+ it('keeps the load error visible when a create refresh fails before first load', async () => {
+ // Initial load fails.
+ mockRequest.mockRejectedValueOnce(new Error('initial load failed'));
+ await expect(memoryStore.attach('space-1')).rejects.toThrow('initial load failed');
+ expect(memoryStore.loaded.value).toBe(false);
+
+ // A create succeeds but its best-effort refresh also fails.
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.create') return makeMemory('beta');
+ if (method === 'agentMemory.list') throw new Error('refresh failed');
+ throw new Error(`unexpected ${method}`);
+ });
+ const entry = await memoryStore.create({ key: 'beta', content: 'x' });
+ expect(entry.key).toBe('beta');
+ // Error stays visible (no stuck spinner) since nothing had loaded.
+ expect(memoryStore.error.value).toBeTruthy();
+ expect(memoryStore.loaded.value).toBe(false);
+ });
+
+ it('restores hasMore when a refresh fails after a full page loaded', async () => {
+ mockRequest.mockResolvedValueOnce(Array.from({ length: 100 }, (_, i) => makeMemory(`a${i}`)));
+ await memoryStore.attach('space-1');
+ expect(memoryStore.hasMore.value).toBe(true);
+
+ // A write succeeds but its refresh fails.
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.write') return makeMemory('new', { updatedAt: 99 });
+ if (method === 'agentMemory.list') throw new Error('refresh failed');
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.write({ key: 'new', content: 'x' });
+ // hasMore restored (not stuck false); error suppressed (a page had loaded).
+ expect(memoryStore.hasMore.value).toBe(true);
+ expect(memoryStore.error.value).toBeNull();
+ });
+
it('optimistically removes a deleted memory', async () => {
mockRequest.mockImplementation(async (method: string) => {
if (method === 'agentMemory.list') return [makeMemory('alpha'), makeMemory('beta')];
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
index 9999c13c82..657d9431ab 100644
--- a/packages/web/src/lib/memory-store.ts
+++ b/packages/web/src/lib/memory-store.ts
@@ -116,6 +116,11 @@ class MemoryStore {
// fails (or is in flight) we don't want Load-more enabled against rows
// from a previous query/space.
this.hasMore.value = false;
+ // Take over from any in-flight loadMore: clear its spinner and invalidate
+ // it so a fetch that never settles can't pin isLoadingMore or block further
+ // pagination via the loadMore guard.
+ this.loadMoreGen++;
+ this.isLoadingMore.value = false;
try {
const rows = await this.fetchPage(spaceId, 0);
if (generation !== this.loadGeneration) return;
@@ -202,8 +207,10 @@ class MemoryStore {
// new space's view with the old space's entry. The write persisted
// server-side and will be visible when the user returns to that space.
if (this.spaceId !== spaceId) return entry;
- // Optimistically reflect the write even if the refresh below fails.
- this.upsertEntry(entry);
+ // Skip the optimistic upsert during an active search: the entry may not
+ // match the query, and inserting it would pollute the ranked results and
+ // re-sort by updatedAt. The reconciling refresh handles it.
+ if (!this.query.value.trim()) this.upsertEntry(entry);
await this.refreshBestEffort();
return entry;
}
@@ -229,7 +236,8 @@ class MemoryStore {
tags: params.tags,
});
if (this.spaceId !== spaceId) return entry;
- this.upsertEntry(entry);
+ // Skip the optimistic upsert during an active search (see write()).
+ if (!this.query.value.trim()) this.upsertEntry(entry);
await this.refreshBestEffort();
return entry;
}
@@ -294,16 +302,30 @@ class MemoryStore {
this.hasMore.value = returned >= PAGE_SIZE;
}
- /** Re-fetch the first page to reconcile, swallowing refresh-only failures. */
+ /**
+ * Re-fetch the first page to reconcile after a mutation, without leaking a
+ * refresh failure into the view. The mutation already persisted, so on
+ * failure we restore the pre-refresh view state (hasMore etc.) rather than
+ * leaving reload's upfront clears in place. The refresh error is suppressed
+ * only when a page had already loaded — if the initial load itself failed,
+ * we leave that error visible so the user isn't left looking at a spinner.
+ */
private async refreshBestEffort(): Promise {
+ const hadLoaded = this.loaded.value;
+ const prevHasMore = this.hasMore.value;
+ const prevError = this.error.value;
try {
await this.reload();
} catch {
- // The mutation already succeeded and the optimistic update above keeps
- // the UI consistent. Suppress the refresh error so the user is never
- // told to retry a write/delete that already persisted; the next
- // load/search reconciles.
- this.error.value = null;
+ // Restore pagination the failed reload cleared upfront.
+ this.hasMore.value = prevHasMore;
+ if (hadLoaded) {
+ // A page was already loaded: keep the UI stable and don't tell the user
+ // to retry a mutation that already persisted.
+ this.error.value = prevError;
+ }
+ // If nothing had loaded yet, leave reload's error set so the load failure
+ // stays visible instead of a permanent spinner.
}
}
From 219532b994a02cefffd7e7a9681d5915f5b947f5 Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 17:08:09 -0400
Subject: [PATCH 10/12] fix(memory): restore offset on failed reconciling
reload
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
refreshBestEffort restored hasMore + error on a failed reload but not
offset, which reload zeroes upfront. With pagination loaded, a transient
refresh failure after a mutation left offset=0 while ~201 rows were
displayed — the next Load-more then re-fetched offset 100 (already
present), de-duped to nothing, and silently no-op'd (cursor stuck
misaligned). Introduced in d4466b21 (the hasMore-restore exposed the
button in that state).
Fix: snapshot prevOffset in refreshBestEffort and restore it in the catch,
keeping the cursor aligned with the displayed rows.
Test: load 2 pages, fail the write's refresh, assert the next Load-more
fetches offset 200 (not 100) and appends fresh rows — the existing
hasMore-restore test didn't do a subsequent loadMore, which is why this
slipped through.
---
.../src/lib/__tests__/memory-store.test.ts | 33 +++++++++++++++++++
packages/web/src/lib/memory-store.ts | 5 +++
2 files changed, 38 insertions(+)
diff --git a/packages/web/src/lib/__tests__/memory-store.test.ts b/packages/web/src/lib/__tests__/memory-store.test.ts
index bdd9166f00..19093b113b 100644
--- a/packages/web/src/lib/__tests__/memory-store.test.ts
+++ b/packages/web/src/lib/__tests__/memory-store.test.ts
@@ -142,6 +142,39 @@ describe('memoryStore', () => {
expect(memoryStore.error.value).toBeNull();
});
+ it('restores offset when a refresh fails, so the next Load-more is not a no-op', async () => {
+ // Pages return 100 distinct rows keyed by offset.
+ mockRequest.mockImplementation(async (_method: string, params: { offset?: number }) => {
+ const offset = params?.offset ?? 0;
+ return Array.from({ length: 100 }, (_, i) => makeMemory(`k${offset + i}`));
+ });
+ await memoryStore.attach('space-1'); // offset 0 → 100 rows
+ await memoryStore.loadMore(); // offset 100 → 200 rows
+ expect(memoryStore.memories.value).toHaveLength(200);
+
+ // A write succeeds but its reconciling reload fails (reload zeroes offset,
+ // then throws). refreshBestEffort must restore the cursor.
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.write') return makeMemory('new', { updatedAt: 999 });
+ if (method === 'agentMemory.list') throw new Error('refresh failed');
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.write({ key: 'new', content: 'x' });
+ expect(memoryStore.hasMore.value).toBe(true);
+
+ // The next Load-more must fetch offset 200 (not 100) and append fresh rows.
+ let loadMoreOffset: number | undefined;
+ mockRequest.mockImplementation(async (_method: string, params: { offset?: number }) => {
+ if (loadMoreOffset === undefined) loadMoreOffset = params?.offset;
+ const offset = params?.offset ?? 0;
+ return Array.from({ length: 100 }, (_, i) => makeMemory(`k${offset + i}`));
+ });
+ await memoryStore.loadMore();
+
+ expect(loadMoreOffset).toBe(200); // cursor advanced past loaded rows, not reset
+ expect(memoryStore.memories.value.length).toBeGreaterThanOrEqual(300); // fresh rows appended
+ });
+
it('optimistically removes a deleted memory', async () => {
mockRequest.mockImplementation(async (method: string) => {
if (method === 'agentMemory.list') return [makeMemory('alpha'), makeMemory('beta')];
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
index 657d9431ab..3289e5839c 100644
--- a/packages/web/src/lib/memory-store.ts
+++ b/packages/web/src/lib/memory-store.ts
@@ -313,12 +313,17 @@ class MemoryStore {
private async refreshBestEffort(): Promise {
const hadLoaded = this.loaded.value;
const prevHasMore = this.hasMore.value;
+ const prevOffset = this.offset;
const prevError = this.error.value;
try {
await this.reload();
} catch {
// Restore pagination the failed reload cleared upfront.
this.hasMore.value = prevHasMore;
+ // Keep the load-more cursor aligned with the displayed rows; otherwise a
+ // reload that zeroed offset (then failed) would make the next Load-more
+ // re-fetch rows already present (a silent no-op).
+ this.offset = prevOffset;
if (hadLoaded) {
// A page was already loaded: keep the UI stable and don't tell the user
// to retry a mutation that already persisted.
From 0a070c64518869f80bf9319e98aac79616f63865 Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 17:28:02 -0400
Subject: [PATCH 11/12] fix(memory): in-place edit during search + editor save
unmount guard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- P2-A: write() now upserts an edited entry in place even during an active
search (the row is already in the filtered set, so updating it can't
pollute). Only a NEW non-matching entry is skipped — create() keeps the
strict gate. Fixes stale content when an edited search result's
reconciling refresh fails.
- P2-B: SpaceMemoryEditor guards handleSave's post-await side effects
with a mountedRef, so a save resolving after the editor unmounts (space
switch) can't call onClose and close a subsequently-opened editor or
discard its input.
Tests: +1 store (edited search result updates in place on failed refresh),
+1 component (stale save across space switch keeps the new editor open).
---
.../components/space/SpaceMemoryEditor.tsx | 19 +++++++++--
.../space/__tests__/SpaceMemories.test.tsx | 34 +++++++++++++++++++
.../src/lib/__tests__/memory-store.test.ts | 25 ++++++++++++++
packages/web/src/lib/memory-store.ts | 13 ++++---
4 files changed, 85 insertions(+), 6 deletions(-)
diff --git a/packages/web/src/components/space/SpaceMemoryEditor.tsx b/packages/web/src/components/space/SpaceMemoryEditor.tsx
index d355d8b663..7bbdbdd130 100644
--- a/packages/web/src/components/space/SpaceMemoryEditor.tsx
+++ b/packages/web/src/components/space/SpaceMemoryEditor.tsx
@@ -10,7 +10,7 @@
* feedback before the round-trip.
*/
-import { useEffect, useState } from 'preact/hooks';
+import { useEffect, useRef, useState } from 'preact/hooks';
import type { AgentMemoryEntry } from '@hyperneo/shared';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
@@ -81,6 +81,16 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
// onClose callback.
const guardedClose = saving ? () => undefined : onClose;
+ // Track mount state so a save that resolves after the editor unmounts (e.g.
+ // a space switch) doesn't call onClose and close a subsequently-opened editor.
+ const mountedRef = useRef(true);
+ useEffect(
+ () => () => {
+ mountedRef.current = false;
+ },
+ []
+ );
+
const handleSave = async () => {
setError(null);
@@ -125,14 +135,19 @@ export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemory
const entry = isEditing
? await memoryStore.write({ key: trimmedKey, content: trimmedContent, tags })
: await memoryStore.create({ key: trimmedKey, content: trimmedContent, tags });
+ // If the editor unmounted while the RPC was in flight (e.g. a space
+ // switch), bail before touching state/onClose — a stale onClose would
+ // close a newly-opened editor and discard its input.
+ if (!mountedRef.current) return;
toast.success(`Memory "${entry.key}" saved`);
onClose();
} catch (err) {
+ if (!mountedRef.current) return;
const message = err instanceof Error ? err.message : 'Failed to save memory';
setError(message);
toast.error(message);
} finally {
- setSaving(false);
+ if (mountedRef.current) setSaving(false);
}
};
diff --git a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
index 759b782886..d79767e861 100644
--- a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
+++ b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
@@ -235,6 +235,40 @@ describe('SpaceMemories', () => {
);
});
+ it('does not close a newly-opened editor when a stale save resolves after a space switch', async () => {
+ const { rerender } = render( );
+
+ // Start a create save in space-1 with a deferred RPC.
+ let resolveStaleSave!: (entry: AgentMemoryEntry) => void;
+ mockCreate.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveStaleSave = resolve;
+ })
+ );
+ fireEvent.click(screen.getByTestId('memory-create-button'));
+ const keyInputA = await screen.findByTestId('memory-key-input');
+ fireEvent.input(keyInputA, { target: { value: 'a-key' } });
+ fireEvent.input(screen.getByTestId('memory-content-input'), { target: { value: 'A body.' } });
+ fireEvent.click(screen.getByTestId('memory-save-button'));
+
+ // Switch to space-2; the [spaceId] effect unmounts editor A.
+ mockCreate.mockResolvedValue(makeMemory('whatever'));
+ rerender( );
+ await waitFor(() => expect(screen.queryByTestId('memory-key-input')).toBeNull());
+
+ // Open a fresh editor in space-2 and type.
+ fireEvent.click(screen.getByTestId('memory-create-button'));
+ const keyInputB = await screen.findByTestId('memory-key-input');
+ fireEvent.input(keyInputB, { target: { value: 'b-key' } });
+
+ // The stale space-1 save resolves — must NOT close editor B or clobber input.
+ resolveStaleSave(makeMemory('a-key'));
+ await waitFor(() =>
+ expect((screen.getByTestId('memory-key-input') as HTMLInputElement).value).toBe('b-key')
+ );
+ });
+
it('blocks create when the key already exists', async () => {
mockMemories.value = [makeMemory('alpha')];
diff --git a/packages/web/src/lib/__tests__/memory-store.test.ts b/packages/web/src/lib/__tests__/memory-store.test.ts
index 19093b113b..0e710994cc 100644
--- a/packages/web/src/lib/__tests__/memory-store.test.ts
+++ b/packages/web/src/lib/__tests__/memory-store.test.ts
@@ -106,6 +106,31 @@ describe('memoryStore', () => {
expect(memoryStore.memories.value.map((m) => m.key)).toEqual(['alpha']);
});
+ it('updates an edited search result in place even when the refresh fails', async () => {
+ // Active search displays alpha.
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.list')
+ return [makeMemory('alpha', { content: 'old', updatedAt: 1 })];
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+ await memoryStore.search('alpha');
+
+ // Edit alpha: the write returns the new content, but the reconciling
+ // refresh (list) fails.
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.write')
+ return makeMemory('alpha', { content: 'new', updatedAt: 2 });
+ if (method === 'agentMemory.list') throw new Error('refresh failed');
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.write({ key: 'alpha', content: 'new' });
+
+ // The displayed row reflects the new content (in-place update, not stale).
+ const alpha = memoryStore.memories.value.find((m) => m.key === 'alpha');
+ expect(alpha?.content).toBe('new');
+ });
+
it('keeps the load error visible when a create refresh fails before first load', async () => {
// Initial load fails.
mockRequest.mockRejectedValueOnce(new Error('initial load failed'));
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
index 3289e5839c..52450acac4 100644
--- a/packages/web/src/lib/memory-store.ts
+++ b/packages/web/src/lib/memory-store.ts
@@ -207,10 +207,15 @@ class MemoryStore {
// new space's view with the old space's entry. The write persisted
// server-side and will be visible when the user returns to that space.
if (this.spaceId !== spaceId) return entry;
- // Skip the optimistic upsert during an active search: the entry may not
- // match the query, and inserting it would pollute the ranked results and
- // re-sort by updatedAt. The reconciling refresh handles it.
- if (!this.query.value.trim()) this.upsertEntry(entry);
+ // During an active search, skip inserting a NEW non-matching entry (it
+ // would pollute the ranked results and re-sort by updatedAt). But an EDIT
+ // of a result already in the filtered set should update in place — it's
+ // already displayed, so updating it can't pollute. (create() keeps the
+ // strict gate: a brand-new memory that doesn't match the query shouldn't
+ // appear mid-search.)
+ if (!this.query.value.trim() || this.memories.value.some((m) => m.key === entry.key)) {
+ this.upsertEntry(entry);
+ }
await this.refreshBestEffort();
return entry;
}
From 2463f26b91e4c97b86b9138ee9a399512f60e38c Mon Sep 17 00:00:00 2001
From: Marc Liu
Date: Mon, 27 Jul 2026 17:44:56 -0400
Subject: [PATCH 12/12] fix(memory): preserve search rank on edit + space-scope
delete completion
- P2-1: editing a search result re-ranked the relevance-ordered list
(upsertEntry sorts by updatedAt). Split the optimistic update: the full
(no-query) list upsert+re-sorts (it's updatedAt-ordered anyway), but an
edited search result is replaced in place via a new replaceEntryInPlace
helper that preserves the backend's BM25+vector order. create() keeps the
strict gate.
- P2-2: handleDeleteConfirm mutated parent state unconditionally post-await;
SpaceMemories is reused across spaces (not remounted), so the editor's
mountedRef didn't cover it. Added a deleteSpaceRef (set in the [spaceId]
effect) captured at confirm time; post-await side effects bail if the
space switched. Also reset the deleting spinner on switch (was missing).
Tests: +1 store (edit preserves relevance order), +1 component (stale
delete across space switch keeps the new modal open).
---
.../src/components/space/SpaceMemories.tsx | 15 +++++++++-
.../space/__tests__/SpaceMemories.test.tsx | 30 +++++++++++++++++++
.../src/lib/__tests__/memory-store.test.ts | 27 +++++++++++++++++
packages/web/src/lib/memory-store.ts | 24 +++++++++++----
4 files changed, 89 insertions(+), 7 deletions(-)
diff --git a/packages/web/src/components/space/SpaceMemories.tsx b/packages/web/src/components/space/SpaceMemories.tsx
index a91af9df61..eda02bd2ae 100644
--- a/packages/web/src/components/space/SpaceMemories.tsx
+++ b/packages/web/src/components/space/SpaceMemories.tsx
@@ -135,6 +135,12 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState(null);
const debounceRef = useRef | null>(null);
+ // Tracks the space the current delete operation was started in. SpaceMemories
+ // is reused across spaces (not remounted), so a delete RPC that resolves after
+ // a space switch must not mutate this component's state (close a new modal,
+ // fire a cross-space toast). Unlike the editor's mountedRef, the component
+ // stays mounted — so we scope by space id instead.
+ const deleteSpaceRef = useRef(null);
useEffect(() => {
// Reset local UI state on space switch so stale modals/search don't carry over.
@@ -143,6 +149,8 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
setEditorOpen(false);
setDeletingMemory(null);
setDeleteError(null);
+ setDeleting(false);
+ deleteSpaceRef.current = spaceId;
// Cancel any in-flight debounced search so it can't fire against the new
// space after detach/attach (Preact reuses the component across spaces, so
// the empty-deps unmount cleanup below does not run on a space switch).
@@ -197,16 +205,21 @@ export function SpaceMemories({ spaceId }: SpaceMemoriesProps) {
const handleDeleteConfirm = async () => {
if (!deletingMemory) return;
const key = deletingMemory.key;
+ const startedSpace = deleteSpaceRef.current;
setDeleting(true);
setDeleteError(null);
try {
await memoryStore.deleteMemory(key);
+ // If the space switched while the delete was in flight, leave the new
+ // space's state alone — don't close its modal or fire a cross-space toast.
+ if (deleteSpaceRef.current !== startedSpace) return;
setDeletingMemory(null);
toast.success(`Memory "${key}" deleted`);
} catch (err) {
+ if (deleteSpaceRef.current !== startedSpace) return;
setDeleteError(err instanceof Error ? err.message : 'Failed to delete memory');
} finally {
- setDeleting(false);
+ if (deleteSpaceRef.current === startedSpace) setDeleting(false);
}
};
diff --git a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
index d79767e861..e6b1fc4f85 100644
--- a/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
+++ b/packages/web/src/components/space/__tests__/SpaceMemories.test.tsx
@@ -395,6 +395,36 @@ describe('SpaceMemories', () => {
await waitFor(() => expect(mockDeleteMemory).toHaveBeenCalledWith('alpha'));
});
+ it('does not close a newly-opened delete modal when a stale delete resolves after a space switch', async () => {
+ mockMemories.value = [makeMemory('x')];
+ const { rerender } = render( );
+
+ // Start a delete for X with a deferred RPC.
+ let resolveStaleDelete!: (result: { deleted: boolean }) => void;
+ mockDeleteMemory.mockImplementation(
+ () =>
+ new Promise<{ deleted: boolean }>((resolve) => {
+ resolveStaleDelete = resolve;
+ })
+ );
+ fireEvent.click(screen.getByTestId('memory-delete-x'));
+ fireEvent.click(screen.getByText('Delete')); // confirm → RPC in flight
+
+ // Switch to space-B (loads Y); the [spaceId] effect closes X's modal.
+ mockMemories.value = [makeMemory('y')];
+ mockDeleteMemory.mockResolvedValue(true);
+ rerender( );
+ await waitFor(() => expect(screen.queryByText('Delete Memory')).toBeNull());
+
+ // Open Y's delete modal in space-B.
+ fireEvent.click(screen.getByTestId('memory-delete-y'));
+ expect(screen.getByText('Delete Memory')).toBeTruthy();
+
+ // X's stale delete resolves — must NOT close Y's modal.
+ resolveStaleDelete({ deleted: true });
+ await waitFor(() => expect(screen.getByText('Delete Memory')).toBeTruthy());
+ });
+
it('surfaces a retry control when loading fails', () => {
mockError.value = 'Failed to load memories';
diff --git a/packages/web/src/lib/__tests__/memory-store.test.ts b/packages/web/src/lib/__tests__/memory-store.test.ts
index 0e710994cc..290819a67a 100644
--- a/packages/web/src/lib/__tests__/memory-store.test.ts
+++ b/packages/web/src/lib/__tests__/memory-store.test.ts
@@ -131,6 +131,33 @@ describe('memoryStore', () => {
expect(alpha?.content).toBe('new');
});
+ it('edits a search result without re-ranking the relevance order', async () => {
+ // Backend returns relevance-ranked [alpha, beta] (independent of updatedAt).
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.list') {
+ return [makeMemory('alpha', { updatedAt: 1 }), makeMemory('beta', { updatedAt: 1 })];
+ }
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.attach('space-1');
+ await memoryStore.search('term'); // relevance order [alpha, beta]
+
+ // Edit beta (the second result); its updatedAt jumps far ahead.
+ mockRequest.mockImplementation(async (method: string) => {
+ if (method === 'agentMemory.write')
+ return makeMemory('beta', { content: 'new', updatedAt: 999 });
+ if (method === 'agentMemory.list') throw new Error('refresh failed');
+ throw new Error(`unexpected ${method}`);
+ });
+ await memoryStore.write({ key: 'beta', content: 'new' });
+
+ const keys = memoryStore.memories.value.map((m) => m.key);
+ // Relevance order preserved — beta must NOT jump to front despite updatedAt 999.
+ expect(keys).toEqual(['alpha', 'beta']);
+ const edited = memoryStore.memories.value.find((m) => m.key === 'beta');
+ expect(edited?.content).toBe('new');
+ });
+
it('keeps the load error visible when a create refresh fails before first load', async () => {
// Initial load fails.
mockRequest.mockRejectedValueOnce(new Error('initial load failed'));
diff --git a/packages/web/src/lib/memory-store.ts b/packages/web/src/lib/memory-store.ts
index 52450acac4..c972545312 100644
--- a/packages/web/src/lib/memory-store.ts
+++ b/packages/web/src/lib/memory-store.ts
@@ -208,13 +208,16 @@ class MemoryStore {
// server-side and will be visible when the user returns to that space.
if (this.spaceId !== spaceId) return entry;
// During an active search, skip inserting a NEW non-matching entry (it
- // would pollute the ranked results and re-sort by updatedAt). But an EDIT
- // of a result already in the filtered set should update in place — it's
- // already displayed, so updating it can't pollute. (create() keeps the
- // strict gate: a brand-new memory that doesn't match the query shouldn't
- // appear mid-search.)
- if (!this.query.value.trim() || this.memories.value.some((m) => m.key === entry.key)) {
+ // would pollute the ranked results). An EDIT of a result already in the
+ // filtered set updates in place WITHOUT re-sorting — search results are
+ // relevance-ordered (BM25+vector), and re-sorting by updatedAt would
+ // discard that order. The full (no-query) list is updatedAt-ordered, so
+ // upsert + re-sort is correct there. (create() keeps the strict gate: a
+ // brand-new memory that doesn't match the query shouldn't appear mid-search.)
+ if (!this.query.value.trim()) {
this.upsertEntry(entry);
+ } else if (this.memories.value.some((m) => m.key === entry.key)) {
+ this.replaceEntryInPlace(entry);
}
await this.refreshBestEffort();
return entry;
@@ -344,6 +347,15 @@ class MemoryStore {
this.memories.value = [...others, entry].sort(compareMemories);
}
+ /**
+ * Replace a single entry by key without re-ordering. Used for in-place edits
+ * of search results, which are relevance-ordered (upsertEntry's updatedAt
+ * sort would discard the backend's BM25+vector rank).
+ */
+ private replaceEntryInPlace(entry: AgentMemoryEntry): void {
+ this.memories.value = this.memories.value.map((m) => (m.key === entry.key ? entry : m));
+ }
+
private removeEntry(key: string): void {
this.memories.value = this.memories.value.filter((m) => m.key !== key);
}