diff --git a/backend/__tests__/service/agent-memory-editor.test.js b/backend/__tests__/service/agent-memory-editor.test.js index 6b2ccbabc9..00f83f263e 100644 --- a/backend/__tests__/service/agent-memory-editor.test.js +++ b/backend/__tests__/service/agent-memory-editor.test.js @@ -138,4 +138,31 @@ describe('owner memory editor', () => { expect(memory.sections.long_term.content).toBe('Old note'); expect(memory.content).toBe('Old note'); }); + + it('index sections carry the section update time and no per-note time', async () => { + await AgentMemory.updateOne({ agentName: 'editor-agent', instanceId: 'machine-1' }, { $set: { + 'sections.long_term.content': '## Rules\nNever merge without a GATE line.\n## Overview\nReviews PRs.', + 'sections.daily': [{ date: '2026-10-28', content: '#401 approved' }], + } }); + const index = await request(app).get('/api/agent-memory/editor-agent/machine-1') + .set('Authorization', `Bearer ${token(owner)}`); + expect(index.status).toBe(200); + const longTerm = index.body.sections.find((s) => s.key === 'long_term'); + const daily = index.body.sections.find((s) => s.key === 'daily'); + expect(longTerm.updatedAt).toBe(new Date('2026-01-01').toISOString()); + expect(daily.updatedAt).toBeNull(); + for (const note of [...longTerm.notes, ...daily.notes]) { + expect(Object.keys(note).sort()).toEqual(['header', 'snippet']); + } + }); + + it('index returns a null section update time when the section has none', async () => { + await AgentMemory.updateOne({ agentName: 'editor-agent', instanceId: 'machine-1' }, { + $set: { 'sections.long_term.content': '## Rules\nNever merge.' }, + $unset: { 'sections.long_term.updatedAt': '' }, + }); + const index = await request(app).get('/api/agent-memory/editor-agent/machine-1') + .set('Authorization', `Bearer ${token(owner)}`); + expect(index.body.sections.find((s) => s.key === 'long_term').updatedAt).toBeNull(); + }); }); diff --git a/backend/routes/agentMemoryView.ts b/backend/routes/agentMemoryView.ts index a3d58149e0..844f5d8a94 100644 --- a/backend/routes/agentMemoryView.ts +++ b/backend/routes/agentMemoryView.ts @@ -55,6 +55,12 @@ const snip = (s: unknown): string => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > SNIPPET ? `${t.slice(0, SNIPPET)}…` : t; }; +// Section last-write time as ISO, or null when the stored value is absent or unparseable. +const isoOrNull = (v: unknown): string | null => { + if (v == null || v === '') return null; + const d = new Date(v as string | number | Date); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +}; // Sections that are internal housekeeping, never shown as "memory". Shared with // the service so the public profile's durable/bookkeeping split and this @@ -278,7 +284,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { .select('sections') .lean(); - const sections: Array<{ key: string; label: string; kind: string; notes: unknown[] }> = []; + const sections: Array<{ key: string; label: string; kind: string; updatedAt: string | null; notes: unknown[] }> = []; let totalEntries = 0; const raw = ((record as Record)?.sections || {}) as Record; @@ -291,7 +297,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { .reverse() .map((d: Record) => ({ header: String(d.date || ''), snippet: snip(d.content) })) .filter((n) => n.header || n.snippet); - if (notes.length) { sections.push({ key, label: 'Daily journal', kind: 'log', notes }); totalEntries += notes.length; } + if (notes.length) { sections.push({ key, label: 'Daily journal', kind: 'log', updatedAt: null, notes }); totalEntries += notes.length; } } else if (key === 'cycles' && value && typeof value === 'object') { const entries = Array.isArray((value as Record).entries) ? ((value as Record).entries as Array>) @@ -300,12 +306,13 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { header: e.ts ? String(e.ts).slice(0, 10) : 'cycle', snippet: snip(e.content), })); - if (notes.length) { sections.push({ key, label: 'Heartbeat cycles', kind: 'log', notes }); totalEntries += notes.length; } + if (notes.length) { sections.push({ key, label: 'Heartbeat cycles', kind: 'log', updatedAt: null, notes }); totalEntries += notes.length; } } else if (value && typeof value === 'object' && 'content' in (value as Record)) { // long_term (and any future doc-shaped section) → index its headers. const notes = indexMarkdown(String((value as Record).content || '')); const label = key === 'long_term' ? 'Long-term memory' : key.replace(/_/g, ' '); - if (notes.length) { sections.push({ key, label, kind: 'doc', notes }); totalEntries += notes.length; } + const updatedAt = isoOrNull((value as Record).updatedAt); + if (notes.length) { sections.push({ key, label, kind: 'doc', updatedAt, notes }); totalEntries += notes.length; } } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 39c1a57761..abb4a81f76 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1954,6 +1954,7 @@ "viewTag": "{{role}} view · private", "indexSummary_one": "{{notes}} notes across {{count}} section{{updated}}. Visible to you; hidden from the public profile.", "indexSummary_other": "{{notes}} notes across {{count}} sections{{updated}}. Visible to you; hidden from the public profile.", + "sectionUpdated": "Section updated {{when}}", "lastSavedClause": " · last saved to {{section}} {{time}}", "more": "+{{count}} more", "entriesRemembered": "entries remembered", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index fc92278ff3..8a5ed43cb5 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1945,6 +1945,7 @@ "viewTag": "{{role}}视图 · 私有", "indexSummary_one": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。", "indexSummary_other": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。", + "sectionUpdated": "板块更新于{{when}}", "lastSavedClause": " · 最后保存至 {{section}},{{time}}", "more": "还有{{count}}条", "entriesRemembered": "条已记住的条目", diff --git a/frontend/src/v2/__tests__/V2AgentMemory.test.tsx b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx index 02897e92c6..39676cdc72 100644 --- a/frontend/src/v2/__tests__/V2AgentMemory.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx @@ -45,6 +45,13 @@ describe('groupSections', () => { const groups = groupSections([section('brand_new_key')]); expect(headersOf(groups, 'how')).toEqual(['brand_new_key 0']); }); + + it('carries the section update time onto its group sections and splits it with long_term', () => { + const at = '2026-10-30T12:00:00.000Z'; + const groups = groupSections([{ ...named('long_term', ['Rules', 'Overview']), updatedAt: at }]); + expect(groups.find((g) => g.id === 'rules').sections[0].updatedAt).toBe(at); + expect(groups.find((g) => g.id === 'how').sections[0].updatedAt).toBe(at); + }); }); describe('V2AgentMemory', () => { @@ -84,6 +91,27 @@ describe('V2AgentMemory', () => { expect(screen.queryByRole('heading', { name: /Journal/ })).toBeNull(); }); + it('shows the section update time once per group and never on a note', async () => { + axios.get.mockResolvedValue({ data: { + viewerRole: 'owner', totalEntries: 3, + sections: [ + { ...named('long_term', ['Rules', 'Host notes']), updatedAt: '2026-10-30T12:00:00.000Z' }, + section('daily', 1), + ], + } }); + render( + + + } /> + + , + ); + await waitFor(() => expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument()); + // Rules and How I work each carry the long_term time; Journal has none. + expect(document.querySelectorAll('.v2-agent-memory__gmeta')).toHaveLength(2); + expect(document.querySelectorAll('.v2-agent-memory__mnote .v2-agent-memory__gmeta')).toHaveLength(0); + }); + it('shows a denied notice instead of the index when the viewer is not owner or admin', async () => { axios.get.mockRejectedValue(new Error('403')); render( diff --git a/frontend/src/v2/agents/V2AgentMemory.tsx b/frontend/src/v2/agents/V2AgentMemory.tsx index 2f815c8c35..a7e40330bf 100644 --- a/frontend/src/v2/agents/V2AgentMemory.tsx +++ b/frontend/src/v2/agents/V2AgentMemory.tsx @@ -3,11 +3,12 @@ import { Link, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import V2PageHead from '../components/V2PageHead'; +import { localizeRelativeTime } from '../utils/localizeRelativeTime'; import './v2-agent-settings.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; notes: MemoryNote[] } +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 @@ -21,7 +22,7 @@ export const MEMORY_GROUPS = [ ] as const; type GroupId = typeof MEMORY_GROUPS[number]['id']; -interface GroupedSection { key: string; label: string; notes: MemoryNote[] } +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. @@ -35,7 +36,7 @@ export const splitLongTerm = (section: MemorySection): Record<'rules' | 'how', G 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, notes } : null + notes.length ? { key: section.key, label: section.label, updatedAt: section.updatedAt ?? null, notes } : null ); return { rules: make(rules), how: make(how) }; }; @@ -44,7 +45,7 @@ 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, notes: soul.notes }); + 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') { @@ -53,7 +54,7 @@ export const groupSections = (sections: MemorySection[]) => { if (split.how) byGroup.how.push(split.how); continue; } - byGroup[sectionGroup(s.key)].push({ key: s.key, label: s.label, notes: s.notes }); + 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, @@ -63,15 +64,16 @@ export const groupSections = (sections: MemorySection[]) => { }; // Frame icons (docs/design/artboards/d215, #s-memory): stroke-only, 1.8 weight. -const ICON_PATHS: Record<'pin' | 'brain' | 'cal' | 'search' | 'info', React.ReactNode> = { +const ICON_PATHS: Record<'pin' | 'brain' | 'cal' | 'search' | 'info' | 'chev', React.ReactNode> = { + chev: , pin: , brain: , cal: <>, search: <>, info: <>, }; -const MemIcon: React.FC<{ name: keyof typeof ICON_PATHS; size?: number }> = ({ name, size = 16 }) => ( -
- {groups.filter((g) => g.sections.length > 0).map((group) => ( -
-

- - {group.label} -

-
    - {group.sections.flatMap((sec) => sec.notes).map((n, i) => ( - group.id === 'journal' ? ( -
  • - {n.header} - {n.snippet} -
  • - ) : ( -
  • - -
    -
    {n.header}
    - {n.snippet &&
    {n.snippet}
    } -
    -
  • - ) - ))} -
-
- ))} + {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.sections.flatMap((sec) => sec.notes).map((n, i) => ( + group.id === 'journal' ? ( +
  • + {n.header} + {n.snippet} +
  • + ) : ( +
  • + +
    +
    {n.header}
    + {n.snippet &&
    {n.snippet}
    } +
    +
  • + ) + ))} +
+
+ ); + })}
); diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css index cacad78458..4d2ce16462 100644 --- a/frontend/src/v2/agents/v2-agent-settings.css +++ b/frontend/src/v2/agents/v2-agent-settings.css @@ -148,8 +148,23 @@ .v2-agent-memory__search:focus-within { box-shadow: var(--v2-focus-ring); } .v2-agent-memory__body { display: grid; min-width: 0; } .v2-agent-memory__group { margin-bottom: 18px; } -.v2-agent-memory__gtitle { display: flex; align-items: center; gap: 8px; margin: 0 4px 8px; font: 500 11.5px/1 var(--v2-font-mono); color: var(--v2-text-tertiary); } +.v2-agent-memory__gtitle { display: flex; align-items: center; gap: 8px; margin: 0 4px 8px; font: 500 11.5px/1 var(--v2-font-mono); color: var(--v2-text-tertiary); list-style: none; } +.v2-agent-memory__gtitle::-webkit-details-marker { display: none; } +.v2-agent-memory__gheading { display: inline-flex; align-items: center; gap: 8px; } .v2-agent-memory__gtitle .v2-agent-memory__icon { width: 13px; height: 13px; } +.v2-agent-memory__gmeta { margin-left: auto; font-weight: 400; font-size: 11px; } +/* Desktop: the groups are not collapsible, so the summary is a plain title. */ +.v2-agent-memory__chev { display: none; } +@media (min-width: 768px) { + .v2-agent-memory__gtitle { pointer-events: none; } +} +/* Phone: search above the groups, group list hidden, groups collapsible. */ +@media (max-width: 767px) { + .v2-agent-memory__nav ul { display: none; } + .v2-agent-memory__gtitle { cursor: pointer; pointer-events: auto; } + .v2-agent-memory__chev { display: inline-block; margin-left: 4px; transition: transform 0.15s; } + .v2-agent-memory__group[open] > .v2-agent-memory__gtitle .v2-agent-memory__chev { transform: rotate(180deg); } +} .v2-agent-memory__list { list-style: none; margin: 0; padding: 0; border-radius: 12px; border: 1px solid var(--v2-border-soft); background: var(--v2-surface); overflow: hidden; } .v2-agent-memory__list > li { border-top: 1px solid var(--v2-border-soft); } .v2-agent-memory__list > li:first-child { border-top: 0; }