Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions backend/__tests__/service/agent-memory-editor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
15 changes: 11 additions & 4 deletions backend/routes/agentMemoryView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>)?.sections || {}) as Record<string, unknown>;

Expand All @@ -291,7 +297,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => {
.reverse()
.map((d: Record<string, unknown>) => ({ 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<string, unknown>).entries)
? ((value as Record<string, unknown>).entries as Array<Record<string, unknown>>)
Expand All @@ -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<string, unknown>)) {
// long_term (and any future doc-shaped section) → index its headers.
const notes = indexMarkdown(String((value as Record<string, unknown>).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<string, unknown>).updatedAt);
if (notes.length) { sections.push({ key, label, kind: 'doc', updatedAt, notes }); totalEntries += notes.length; }
}
}

Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1945,6 +1945,7 @@
"viewTag": "{{role}}视图 · 私有",
"indexSummary_one": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。",
"indexSummary_other": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。",
"sectionUpdated": "板块更新于{{when}}",
"lastSavedClause": " · 最后保存至 {{section}},{{time}}",
"more": "还有{{count}}条",
"entriesRemembered": "条已记住的条目",
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/v2/__tests__/V2AgentMemory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={['/v2/agents/maya-claude/default/memory']}>
<Routes>
<Route path="/v2/agents/:agentName/:instanceId/memory" element={<V2AgentMemory />} />
</Routes>
</MemoryRouter>,
);
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(
Expand Down
82 changes: 48 additions & 34 deletions frontend/src/v2/agents/V2AgentMemory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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) };
};
Expand All @@ -44,7 +45,7 @@ export const groupSections = (sections: MemorySection[]) => {
const byGroup: Record<GroupId, GroupedSection[]> = { 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') {
Expand All @@ -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,
Expand All @@ -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: <path d="m6 9 6 6 6-6" />,
pin: <path d="M12 17v5M8 3h8l-1 6 3 4H6l3-4z" />,
brain: <path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1V5a2 2 0 0 0-3-1zM15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />,
cal: <><rect x="3" y="5" width="18" height="16" rx="2.5" /><path d="M3 10h18M8 3v4M16 3v4" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></>,
info: <><circle cx="12" cy="12" r="9" /><path d="M12 16v-4M12 8h.01" /></>,
};
const MemIcon: React.FC<{ name: keyof typeof ICON_PATHS; size?: number }> = ({ name, size = 16 }) => (
<svg className="v2-agent-memory__icon" viewBox="0 0 24 24" width={size} height={size} aria-hidden="true"
const MemIcon: React.FC<{ name: keyof typeof ICON_PATHS; size?: number; className?: string }> = ({ name, size = 16, className }) => (
<svg className={`v2-agent-memory__icon${className ? ` ${className}` : ''}`} viewBox="0 0 24 24" width={size} height={size} aria-hidden="true"
fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
{ICON_PATHS[name]}
</svg>
Expand Down Expand Up @@ -159,32 +161,44 @@ const V2AgentMemory: React.FC = () => {
</label>
</aside>
<div className="v2-agent-memory__body">
{groups.filter((g) => g.sections.length > 0).map((group) => (
<section key={group.id} className="v2-agent-memory__group" aria-labelledby={`memory-group-${group.id}`}>
<h2 id={`memory-group-${group.id}`} className="v2-agent-memory__gtitle">
<MemIcon name={GROUP_ICON[group.id]} size={13} />
{group.label}
</h2>
<ul className="v2-agent-memory__list">
{group.sections.flatMap((sec) => sec.notes).map((n, i) => (
group.id === 'journal' ? (
<li key={i} className="v2-agent-memory__jday">
<span className="v2-agent-memory__jdate">{n.header}</span>
<span>{n.snippet}</span>
</li>
) : (
<li key={i} className="v2-agent-memory__mnote">
<MemIcon name={GROUP_ICON[group.id]} />
<div>
<div className="v2-agent-memory__tx">{n.header}</div>
{n.snippet && <div className="v2-agent-memory__snip">{n.snippet}</div>}
</div>
</li>
)
))}
</ul>
</section>
))}
{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 (
<details key={group.id} id={`memory-group-${group.id}`} className="v2-agent-memory__group" open>
<summary className="v2-agent-memory__gtitle">
<span role="heading" aria-level={2} className="v2-agent-memory__gheading">
<MemIcon name={GROUP_ICON[group.id]} size={13} />
{group.label}
</span>
{updated && (
<span className="v2-agent-memory__gmeta">
{t('agentProfile.memory.sectionUpdated', { defaultValue: 'Section updated {{when}}', when: localizeRelativeTime(updated, t) })}
</span>
)}
<MemIcon name="chev" size={13} className="v2-agent-memory__chev" />
</summary>
<ul className="v2-agent-memory__list">
{group.sections.flatMap((sec) => sec.notes).map((n, i) => (
group.id === 'journal' ? (
<li key={i} className="v2-agent-memory__jday">
<span className="v2-agent-memory__jdate">{n.header}</span>
<span>{n.snippet}</span>
</li>
) : (
<li key={i} className="v2-agent-memory__mnote">
<MemIcon name={GROUP_ICON[group.id]} />
<div>
<div className="v2-agent-memory__tx">{n.header}</div>
{n.snippet && <div className="v2-agent-memory__snip">{n.snippet}</div>}
</div>
</li>
)
))}
</ul>
</details>
);
})}
</div>
</div>
);
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/v2/agents/v2-agent-settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading