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 945003d61..f554a4693 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( @@ -37,7 +51,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) => { @@ -51,6 +67,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, }); }); } @@ -100,6 +119,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/src/storage/repositories/agent-memory-repository.ts b/packages/daemon/src/storage/repositories/agent-memory-repository.ts index 302cec36d..ae62eccc7 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; @@ -148,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, @@ -180,16 +216,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 @@ -417,7 +458,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); @@ -429,7 +470,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 32a732431..66ff00278 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> = []; @@ -82,4 +105,42 @@ 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); + }); + + 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/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 0c0a42eec..fa86e39fe 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,53 @@ 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', + 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/shared/src/mod.ts b/packages/shared/src/mod.ts index b75c06e14..cc6729e6e 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 000000000..3c7617939 --- /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; +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 2198b62b8..ea78bec03 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/components/space/SpaceMemories.tsx b/packages/web/src/components/space/SpaceMemories.tsx new file mode 100644 index 000000000..eda02bd2a --- /dev/null +++ b/packages/web/src/components/space/SpaceMemories.tsx @@ -0,0 +1,405 @@ +/** + * 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; +} + +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 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(''); + 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); + // 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. + setSearchInput(''); + setEditorMemory(null); + 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). + 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]); + + // 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; + 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 { + if (deleteSpaceRef.current === startedSpace) setDeleting(false); + } + }; + + const handleRetry = () => { + memoryStore.reload().catch(() => { + // Error surfaced via memoryStore.error signal. + }); + }; + + if (!loaded && !error) { + return ( +
+
+ Loading memories... +
+
+ ); + } + + const trimmedSearch = searchInput.trim(); + + return ( +
+
+
+
+
+
+

+ Memories · {memories.length} + {hasMore ? '+' : ''} {searchActive ? 'results' : '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) => ( + + ))} + {hasMore && ( +
+ +
+ )} +
+
+ )} + + {editorOpen && ( + memory.key)} + onClose={handleEditorClose} + /> + )} + + {deletingMemory && ( + { + // 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); + }} + 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 000000000..7bbdbdd13 --- /dev/null +++ b/packages/web/src/components/space/SpaceMemoryEditor.tsx @@ -0,0 +1,247 @@ +/** + * 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 { useEffect, useRef, 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; +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. */ + memory: AgentMemoryEntry | null; + /** Keys already present in the space, to warn on create-mode collisions. */ + existingKeys: string[]; + onClose: () => void; +} + +function parseTagsInput(input: string): string[] { + return input + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); +} + +export function SpaceMemoryEditor({ memory, existingKeys, onClose }: SpaceMemoryEditorProps) { + const isEditing = memory !== null; + 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 + // 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. + // 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; + + // 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); + + const trimmedContent = content.trim(); + + if (!isEditing && !trimmedKey) { + 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; + } + // 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); + try { + // 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 }); + // 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 { + if (mountedRef.current) 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.'} +

+ {duplicateKey && ( +

+ A memory with this key already exists — edit it instead, or choose a different key. +

+ )} +
+ +
+ +