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} + + ))} +
+
+
+ + +
+
+
+ ); +} + +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. +

+
+
+ +
+ +
+ + 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 && ( + + )} +
+
+ + {error && ( +
+ {error} + +
+ )} + + {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. +

+
+ +
+ + )} +
+ ) : ( +
+
+ {memories.map((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 ( + +
+
+ + 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.'} +

+
+ +
+ +