import React, { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import V2Avatar from '../components/V2Avatar'; import { localizeRelativeTime } from '../utils/localizeRelativeTime'; import '../primitives/primitives.css'; import './V2AgentMemory.css'; // Owner/admin memory index, as served by GET /api/agent-memory/:agentName/:instanceId. interface MemoryNote { header: string; snippet: string } interface MemorySection { key: string; label: string; kind: string; updatedAt?: string | null; notes: MemoryNote[] } interface MemoryIndex { viewerRole: 'owner' | 'admin'; totalEntries: number; sections: MemorySection[] } // Which backend sections land in which group. Journal is the dated log. Soul // is a rule-like section and sits at the top of Rules. Long-term memory is split // by its own headings (see splitLongTerm). Any other section lands in How I work // so nothing is hidden. Change the mapping here only. export const MEMORY_GROUPS = [ { id: 'rules', label: 'Rules' }, { id: 'how', label: 'How I work' }, { id: 'journal', label: 'Journal' }, ] as const; type GroupId = typeof MEMORY_GROUPS[number]['id']; interface GroupedSection { key: string; label: string; updatedAt: string | null; notes: MemoryNote[] } const JOURNAL_KEYS = new Set(['daily', 'cycles']); // A long_term heading whose first word names a rule goes under Rules. const RULE_HEADING = /^(rules?|never|always|don'?t)\b/i; const sectionGroup = (key: string): GroupId => (JOURNAL_KEYS.has(key) ? 'journal' : 'how'); // Splits a long_term section's headings into Rules and How I work. The // "Overview" note (text before the first heading) is How I work. export const splitLongTerm = (section: MemorySection): Record<'rules' | 'how', GroupedSection | null> => { const rules = section.notes.filter((n) => RULE_HEADING.test(n.header)); const how = section.notes.filter((n) => !RULE_HEADING.test(n.header)); const make = (notes: MemoryNote[]): GroupedSection | null => ( notes.length ? { key: section.key, label: section.label, updatedAt: section.updatedAt ?? null, notes } : null ); return { rules: make(rules), how: make(how) }; }; export const groupSections = (sections: MemorySection[]) => { const byGroup: Record = { rules: [], how: [], journal: [] }; // Soul first, so it leads Rules. const soul = sections.find((s) => s.key === 'soul'); if (soul) byGroup.rules.push({ key: soul.key, label: soul.label, updatedAt: soul.updatedAt ?? null, notes: soul.notes }); for (const s of sections) { if (s.key === 'soul') continue; if (s.key === 'long_term') { const split = splitLongTerm(s); if (split.rules) byGroup.rules.push(split.rules); if (split.how) byGroup.how.push(split.how); continue; } byGroup[sectionGroup(s.key)].push({ key: s.key, label: s.label, updatedAt: s.updatedAt ?? null, notes: s.notes }); } return MEMORY_GROUPS.map((g) => ({ id: g.id, label: g.label, sections: byGroup[g.id], })); }; // Frame icons (docs/design/artboards/d215, #s-memory): stroke-only, 1.8 weight. const ICON_PATHS: Record<'pin' | 'brain' | 'cal' | 'search' | 'info' | 'chev' | 'chat', React.ReactNode> = { chev: , chat: , pin: , brain: , cal: <>, search: <>, info: <>, }; const MemIcon: React.FC<{ name: keyof typeof ICON_PATHS; size?: number; className?: string }> = ({ name, size = 16, className }) => ( ); const GROUP_ICON: Record = { rules: 'pin', how: 'brain', journal: 'cal' }; // The D215 frame without entries: same nav and body, the state sits inside it. const MemoryShell: React.FC<{ loading: boolean; message: string; loadingLabel: string; query: string; setQuery: (q: string) => void; searchLabel: string; navLabel: string }> = ({ loading, message, loadingLabel, query, setQuery, searchLabel, navLabel }) => (

{loading ? loadingLabel : message}

); const V2AgentMemory: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>(); const [index, setIndex] = useState(null); const [state, setState] = useState<'loading' | 'ready' | 'denied'>('loading'); const [query, setQuery] = useState(''); const [activeGroup, setActiveGroup] = useState(null); const [openingRoom, setOpeningRoom] = useState(false); const [roomError, setRoomError] = useState(false); // Public header facts (name, avatar, pod count). Readable by any viewer, so the // shell renders the same for owners, admins and everyone else. const [head, setHead] = useState<{ displayName: string; profilePicture?: string; pods: number } | null>(null); useEffect(() => { let cancelled = false; axios .get(`/api/agent-profile/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId || 'default')}`) .then((res) => { if (!cancelled) setHead({ displayName: res.data?.agent?.displayName || agentName, profilePicture: res.data?.agent?.profilePicture, pods: res.data?.pods?.count ?? 0 }); }) .catch(() => { if (!cancelled) setHead(null); }); return () => { cancelled = true; }; }, [agentName, instanceId]); useEffect(() => { let cancelled = false; setState('loading'); axios .get(`/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId || 'default')}`) .then((res) => { if (cancelled) return; setIndex(res.data); setState('ready'); }) .catch(() => { if (!cancelled) setState('denied'); }); return () => { cancelled = true; }; }, [agentName, instanceId]); // Same idempotent room endpoint as the profile and Your Team entry points: // returns the existing DM or opens one. const talkTo = async () => { setOpeningRoom(true); setRoomError(false); try { const res = await axios.post<{ room?: { _id?: string } }>('/api/agents/runtime/room', { agentName, instanceId: instanceId || 'default' }); const roomId = res.data?.room?._id; if (!roomId) throw new Error('Agent room not returned'); navigate(`/v2/pods/${roomId}`); } catch { setRoomError(true); setOpeningRoom(false); } }; const profileHref = `/v2/agent/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`; const settingsHref = `/v2/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`; const memoryLabel = t('agentSettings.memory', { defaultValue: 'Memory' }); const searchLabel = t('agentProfile.memory.search', { defaultValue: 'Search memory' }); return (

{head?.displayName || agentName}

{head &&

{t('agentProfile.hero.activeInPods', { count: head.pods })}

}
{roomError &&

{t('agentProfile.hero.openRoomFailed')}

}
{state === 'ready' && index ? null : } {state === 'ready' && index && (() => { const needle = query.trim().toLowerCase(); const groups = groupSections(index.sections) .map((group) => ({ ...group, sections: group.sections .map((sec) => ({ ...sec, notes: needle ? sec.notes.filter((n) => `${n.header} ${n.snippet}`.toLowerCase().includes(needle)) : sec.notes, })) .filter((sec) => sec.notes.length > 0), })); const firstShown = groups.find((g) => g.sections.length > 0)?.id; // The frame marks one nav item as current: the one clicked, else the first with notes. const current = groups.find((g) => g.id === activeGroup && g.sections.length > 0)?.id ?? firstShown; return (
{groups.filter((g) => g.sections.length > 0).map((group) => { // One time per group: the newest section update in it. Never per note. const updated = group.sections.map((s) => s.updatedAt).filter(Boolean).sort().pop() ?? null; return (
{group.label} {updated && ( {t('agentProfile.memory.sectionUpdated', { defaultValue: 'Section updated {{when}}', when: localizeRelativeTime(updated, t) })} )} {group.id === firstShown && ( )}
    {group.sections.flatMap((sec) => sec.notes).map((n, i) => ( group.id === 'journal' ? (
  • {n.header} {n.snippet}
  • ) : (
  • {n.header}
    {n.snippet &&
    {n.snippet}
    }
  • ) ))}
); })}
); })()}
); }; export default V2AgentMemory;