diff --git a/backend/__tests__/service/agent-memory-envelope.test.js b/backend/__tests__/service/agent-memory-envelope.test.js index 3594fefe2..db06df56a 100644 --- a/backend/__tests__/service/agent-memory-envelope.test.js +++ b/backend/__tests__/service/agent-memory-envelope.test.js @@ -627,6 +627,7 @@ describe('AgentMemory envelope — GET/PUT /memory + backfill', () => { const matched = dailyArr.find((d) => d?.date === today); expect(matched).toBeTruthy(); expect(matched.content).toBe('natural write without date'); + expect(Date.now() - new Date(matched.updatedAt).getTime()).toBeLessThan(60_000); }); it('full mode: replaces the entire sections envelope', async () => { diff --git a/backend/__tests__/service/agent-profile-memory-write.test.js b/backend/__tests__/service/agent-profile-memory-write.test.js new file mode 100644 index 000000000..4ed6bc98c --- /dev/null +++ b/backend/__tests__/service/agent-profile-memory-write.test.js @@ -0,0 +1,83 @@ +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); + +// This anonymous profile route does not exercise JWT behavior. Mock it so the +// route's unrelated auth-module import stays runnable on Node 26, where this +// repo's legacy jsonwebtoken transitive dependency fails during module load. +jest.mock('jsonwebtoken', () => ({ + sign: jest.fn(), + verify: jest.fn(), + decode: jest.fn(), +})); + +const { MONGO_BINARY_VERSION, MONGOMS_DOWNLOAD_DIR } = require('../utils/mongoBinaryConfig'); +const User = require('../../models/User'); +const AgentMemory = require('../../models/AgentMemory'); +const agentProfileRoutes = require('../../routes/agentProfile'); + +describe('Agent profile memory activity', () => { + let app; + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create({ + binary: { version: MONGO_BINARY_VERSION, downloadDir: MONGOMS_DOWNLOAD_DIR, skipMD5: true }, + instance: { dbName: 'agent-profile-memory-write' }, + }); + await mongoose.connect(mongoServer.getUri()); + app = express(); + app.use('/api/agent-profile', agentProfileRoutes); + }); + + afterEach(async () => { + await mongoose.connection.dropDatabase(); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + it('reports the latest agent-authored write as a KIND, not a newer system exchange', async () => { + await User.create({ + username: 'memory-observer', + email: 'memory-observer@test.com', + password: 'Password123!', + isBot: true, + botMetadata: { agentName: 'claude-code', instanceId: 'observer' }, + }); + await AgentMemory.create({ + agentName: 'claude-code', + instanceId: 'observer', + sections: { + long_term: { + content: 'Durable review state', + visibility: 'private', + updatedAt: new Date('2026-08-26T09:00:00Z'), + }, + system_exchanges: { + entries: [], + visibility: 'private', + updatedAt: new Date('2026-08-26T10:00:00Z'), + }, + }, + }); + + const res = await request(app).get('/api/agent-profile/claude-code/observer'); + + expect(res.status).toBe(200); + // This route is unauthenticated: it reports THAT the seat wrote something + // durable, never WHICH section. Selection still has to pick long_term over + // the newer system_exchanges bump — that is what a real store exercises + // here, on top of the mocked unit cover in + // __tests__/unit/routes/agentProfile.memoryWrite.test.js. + expect(res.body.memory.lastAgentWrite).toEqual({ + kind: 'durable', + updatedAt: '2026-08-26T09:00:00.000Z', + }); + expect(res.body.memory.lastAgentWrite.section).toBeUndefined(); + expect(res.body.memory.updatedAt).toBeUndefined(); + }); +}); diff --git a/backend/__tests__/unit/models/AgentMemory.test.ts b/backend/__tests__/unit/models/AgentMemory.test.ts index 425c76691..6c2fcfff1 100644 --- a/backend/__tests__/unit/models/AgentMemory.test.ts +++ b/backend/__tests__/unit/models/AgentMemory.test.ts @@ -58,6 +58,24 @@ describe('AgentMemory (ADR-003 v2 schema)', () => { expect(doc.schemaVersion).toBe(2); }); + it('does not fabricate write times while hydrating legacy section records', async () => { + await AgentMemory.collection.insertOne({ + agentName: 'openclaw', + instanceId: 'legacy-no-section-stamps', + content: '', + sections: { + long_term: { content: 'legacy durable state', visibility: 'private', byteSize: 20 }, + daily: [{ date: '2026-08-01', content: 'legacy journal', visibility: 'private' }], + relationships: [{ otherInstanceId: 'nova', notes: 'legacy note', visibility: 'private' }], + }, + }); + + const doc = await AgentMemory.findOne({ instanceId: 'legacy-no-section-stamps' }); + expect(doc.sections?.long_term?.updatedAt).toBeUndefined(); + expect(doc.sections?.daily?.[0]?.updatedAt).toBeUndefined(); + expect(doc.sections?.relationships?.[0]?.updatedAt).toBeUndefined(); + }); + it('defaults visibility to "private" on sections', async () => { const doc = await AgentMemory.create({ agentName: 'openclaw', diff --git a/backend/__tests__/unit/routes/agentProfile.memoryWrite.test.js b/backend/__tests__/unit/routes/agentProfile.memoryWrite.test.js new file mode 100644 index 000000000..f8a374444 --- /dev/null +++ b/backend/__tests__/unit/routes/agentProfile.memoryWrite.test.js @@ -0,0 +1,124 @@ +/** + * The public agent profile is mounted WITHOUT auth (see the file header of + * routes/agentProfile.ts), so it may report THAT an agent saved and when — + * never WHICH section. The owner/admin memory view keeps the exact section; + * both derive from the same max() over AGENT_WRITABLE_SECTIONS. + */ + +jest.mock('../../../models/User', () => ({ findOne: jest.fn(), findById: jest.fn() })); +jest.mock('../../../models/Pod', () => ({ find: jest.fn(), countDocuments: jest.fn() })); +// Keep the real AGENT_WRITABLE_SECTIONS — the selection rule under test reads +// it — and stub only the query. +jest.mock('../../../models/AgentMemory', () => ({ + ...jest.requireActual('../../../models/AgentMemory'), + findOne: jest.fn(), +})); +jest.mock('../../../models/AgentRun', () => ({ find: jest.fn() })); +jest.mock('../../../models/PodAsset', () => ({ find: jest.fn() })); +jest.mock('../../../models/pg/Message', () => ({})); +jest.mock('../../../models/AgentRegistry', () => ({ + AgentInstallation: { findOne: jest.fn(), find: jest.fn() }, + AgentRegistry: { findOne: jest.fn(), updateOne: jest.fn() }, +})); +jest.mock('../../../services/agentIdentityService', () => ({ + resolveAgentDisplayLabel: jest.fn((u, f) => f), + syncUserToPostgreSQL: jest.fn(), +})); +jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => next())); + +const User = require('../../../models/User'); +const Pod = require('../../../models/Pod'); +const AgentMemory = require('../../../models/AgentMemory'); +const AgentRun = require('../../../models/AgentRun'); +const PodAsset = require('../../../models/PodAsset'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const router = require('../../../routes/agentProfile'); + +// The route swallows handler errors into a 500. Capture what it logged so a +// wiring failure names itself instead of arriving as a passing "no section +// name" assertion on an empty body. +let handlerError; +jest.spyOn(console, 'error').mockImplementation((...args) => { handlerError = args; }); + +const getHandler = (method, path) => { + const layer = router.stack.find((entry) => ( + entry.route && entry.route.path === path && entry.route.methods[method] + )); + if (!layer) throw new Error(`${method.toUpperCase()} ${path} handler not found`); + return layer.route.stack[layer.route.stack.length - 1].handle; +}; + +const response = () => { + const res = { statusCode: 200, body: undefined }; + res.status = (code) => { res.statusCode = code; return res; }; + res.json = (payload) => { res.body = payload; return res; }; + return res; +}; + +const lean = (value) => ({ lean: jest.fn().mockResolvedValue(value) }); +const selectLean = (value) => ({ select: jest.fn().mockReturnValue(lean(value)) }); + +const UPDATED_AT = new Date('2026-08-26T10:00:00Z'); + +const runProfile = async (sections) => { + User.findOne.mockReturnValue(selectLean({ + _id: 'agent-user', username: 'observer', isBot: true, profilePicture: 'default', + botMetadata: { agentName: 'claude-code', instanceId: 'observer', capabilities: [] }, + createdAt: new Date('2026-01-01T00:00:00Z'), + })); + PodAsset.find.mockReturnValue(selectLean([])); + AgentInstallation.find.mockReturnValue(selectLean([])); + Pod.find.mockReturnValue(selectLean([])); + AgentMemory.findOne.mockReturnValue(selectLean({ sections })); + AgentRun.find.mockReturnValue({ + sort: () => ({ limit: () => selectLean([]) }), + }); + + const res = response(); + await getHandler('get', '/:agentName/:instanceId?')( + { params: { agentName: 'claude-code', instanceId: 'observer' }, query: {} }, + res, + ); + if (res.statusCode !== 200) { + throw new Error(`handler ${res.statusCode}: ${JSON.stringify(handlerError)}`); + } + return res; +}; + +describe('GET /:agentName/:instanceId — last agent write', () => { + beforeEach(() => jest.clearAllMocks()); + + it('reports a durable write as a kind, without the section name', async () => { + const res = await runProfile({ + long_term: { content: 'durable decision', updatedAt: UPDATED_AT }, + }); + + expect(res.statusCode).toBe(200); + expect(res.body.memory.lastAgentWrite).toEqual({ kind: 'durable', updatedAt: UPDATED_AT }); + }); + + it('reports a housekeeping write as bookkeeping, without the section name', async () => { + const res = await runProfile({ + runtime_meta: { content: 'runtime snapshot', updatedAt: UPDATED_AT }, + }); + + expect(res.body.memory.lastAgentWrite).toEqual({ kind: 'bookkeeping', updatedAt: UPDATED_AT }); + }); + + it('never emits a section name on this unauthenticated route', async () => { + const res = await runProfile({ + dedup_state: { content: 'message ids', updatedAt: UPDATED_AT }, + }); + + expect(JSON.stringify(res.body)).not.toContain('dedup_state'); + expect(Object.keys(res.body.memory.lastAgentWrite).sort()).toEqual(['kind', 'updatedAt']); + }); + + it('omits the field entirely when only automated writers have touched the envelope', async () => { + const res = await runProfile({ + system_exchanges: { entries: [], visibility: 'private', updatedAt: UPDATED_AT }, + }); + + expect(res.body.memory.lastAgentWrite).toBeNull(); + }); +}); diff --git a/backend/__tests__/unit/services/agentMemoryService.test.ts b/backend/__tests__/unit/services/agentMemoryService.test.ts index 969013603..9b026ba9a 100644 --- a/backend/__tests__/unit/services/agentMemoryService.test.ts +++ b/backend/__tests__/unit/services/agentMemoryService.test.ts @@ -7,6 +7,9 @@ const { buildSectionsFromLegacyContent, mirrorContentFromSections, stampSectionsForWrite, + getLastAgentMemoryWrite, + coarsenAgentMemoryWrite, + classifyAgentWriteSection, mergePatchSections, computeSyncDedupKey, isValidYMD, @@ -192,7 +195,7 @@ describe('stampSectionsForWrite', () => { expect(out.soul).toBeUndefined(); }); - it('stamps daily entries without byteSize/updatedAt (per ADR shape)', () => { + it('server-stamps daily entries without byteSize', () => { const out = stampSectionsForWrite({ daily: [{ date: '2026-04-14', content: 'today', visibility: 'pod' }], }, FIXED); @@ -201,7 +204,7 @@ describe('stampSectionsForWrite', () => { expect(out.daily[0].content).toBe('today'); expect(out.daily[0].visibility).toBe('pod'); expect(out.daily[0].byteSize).toBeUndefined(); - expect(out.daily[0].updatedAt).toBeUndefined(); + expect(out.daily[0].updatedAt).toEqual(FIXED); }); it('stamps relationships entries with updatedAt but no byteSize', () => { @@ -229,6 +232,111 @@ describe('stampSectionsForWrite', () => { }); }); +describe('getLastAgentMemoryWrite', () => { + it('uses the newest normal agent-save section, not newer automated entries', () => { + const latest = getLastAgentMemoryWrite({ + long_term: { content: 'durable state', updatedAt: new Date('2026-08-26T08:00:00Z') }, + relationships: [{ + otherInstanceId: 'architect', + notes: 'reviewer', + updatedAt: new Date('2026-08-26T09:00:00Z'), + }], + daily: [{ + date: '2026-08-26', + content: 'shipped memory observability', + updatedAt: new Date('2026-08-26T10:00:00Z'), + }], + system_exchanges: { + entries: [], + visibility: 'private', + updatedAt: new Date('2026-08-26T11:00:00Z'), + }, + cycles: { + entries: [], + visibility: 'private', + updatedAt: new Date('2026-08-26T12:00:00Z'), + }, + }); + + expect(latest).toEqual({ + section: 'daily', + updatedAt: new Date('2026-08-26T10:00:00Z'), + }); + }); + + it('returns null when the envelope contains only automated or journal writes', () => { + expect(getLastAgentMemoryWrite({ + system_exchanges: { + entries: [], visibility: 'private', updatedAt: new Date('2026-08-26T11:00:00Z'), + }, + cycles: { + entries: [], visibility: 'private', updatedAt: new Date('2026-08-26T12:00:00Z'), + }, + })).toBeNull(); + }); + + // A tie is the common case: one /memory/sync stamps every section it carries + // with the same `now`. The previous fixture here omitted `soul`, so it read as + // pinning a preference for long_term when the code was only doing array order + // — `soul` sorts first and would have won. Both facts are now stated. + // The fixture has to DISCRIMINATE. `soul` and `long_term` are both durable and + // both sort ahead of every bookkeeping section, so any tie including them is + // won by array order alone and passes with the rule deleted — verified by + // mutation, which left an earlier version of this test green. `dedup_state` + // (index 2, bookkeeping) ahead of `shared` (index 3, durable) is the only + // shape where the two rules disagree. + it('gives a tie to a durable section over a bookkeeping one that sorts ahead of it', () => { + const updatedAt = new Date('2026-08-26T10:00:00Z'); + expect(getLastAgentMemoryWrite({ + dedup_state: { content: 'message ids', updatedAt }, + shared: { content: 'durable note', updatedAt }, + })).toEqual({ section: 'shared', updatedAt }); + expect(classifyAgentWriteSection('shared')).toBe('durable'); + }); + + it('still reports a bookkeeping section when nothing durable is that recent', () => { + const updatedAt = new Date('2026-08-26T10:00:00Z'); + expect(getLastAgentMemoryWrite({ + long_term: { content: 'older', updatedAt: new Date('2026-08-25T10:00:00Z') }, + dedup_state: { content: 'message ids', updatedAt }, + })).toEqual({ section: 'dedup_state', updatedAt }); + }); + + it('breaks a durable-vs-durable tie by section order, which nothing may depend on', () => { + const updatedAt = new Date('2026-08-26T10:00:00Z'); + expect(getLastAgentMemoryWrite({ + soul: { content: 'who I am', updatedAt }, + long_term: { content: 'durable decision', updatedAt }, + })).toEqual({ section: 'soul', updatedAt }); + }); +}); + +describe('coarsenAgentMemoryWrite', () => { + // The profile route is mounted without auth, so it may report THAT an agent + // saved and when, never WHICH section. Same max(), two shapes. + const updatedAt = new Date('2026-08-26T10:00:00Z'); + + it.each(['long_term', 'soul', 'shared', 'daily', 'relationships'])( + 'reports %s as durable', + (section) => { + expect(coarsenAgentMemoryWrite({ section, updatedAt })).toEqual({ kind: 'durable', updatedAt }); + }, + ); + + it.each(['dedup_state', 'runtime_meta'])('reports %s as bookkeeping', (section) => { + expect(coarsenAgentMemoryWrite({ section, updatedAt })).toEqual({ kind: 'bookkeeping', updatedAt }); + }); + + it('never carries the section name through', () => { + const coarse = coarsenAgentMemoryWrite({ section: 'runtime_meta', updatedAt }); + expect(Object.keys(coarse).sort()).toEqual(['kind', 'updatedAt']); + }); + + it('passes a null write straight through', () => { + expect(coarsenAgentMemoryWrite(null)).toBeNull(); + }); +}); + describe('isValidYMD', () => { it('accepts valid YYYY-MM-DD', () => { expect(isValidYMD('2026-04-14')).toBe(true); diff --git a/backend/models/AgentMemory.ts b/backend/models/AgentMemory.ts index 539bbfb98..22fd7a294 100644 --- a/backend/models/AgentMemory.ts +++ b/backend/models/AgentMemory.ts @@ -33,7 +33,8 @@ export const MEMORY_HISTORY_CAP = 10; export interface IMemorySection { content: string; visibility: MemoryVisibility; - updatedAt: Date; + // Server-stamped write time. Optional on records written before TASK-076. + updatedAt?: Date; byteSize: number; source?: IMemoryWriteSource; history?: IMemorySectionVersion[]; @@ -43,13 +44,18 @@ export interface IDailySection { date: string; // YYYY-MM-DD content: string; visibility: MemoryVisibility; + // Server-stamped write time. `date` identifies the journal day, not when + // the agent last changed that entry. Optional for legacy entries written + // before TASK-076; never default it during document hydration. + updatedAt?: Date; } export interface IRelationshipNote { otherInstanceId: string; notes: string; visibility: MemoryVisibility; - updatedAt: Date; + // Server-stamped write time. Optional on records written before TASK-076. + updatedAt?: Date; } // ADR-012 §1: structured entries for system-driven exchange records. @@ -194,7 +200,7 @@ const memorySectionSchema = new Schema( { content: { type: String, default: '' }, visibility: { type: String, enum: VISIBILITY_VALUES, default: 'private' }, - updatedAt: { type: Date, default: Date.now }, + updatedAt: { type: Date }, byteSize: { type: Number, default: 0 }, source: { type: memoryWriteSourceSchema, required: false }, history: { type: [memorySectionVersionSchema], required: false, default: undefined }, @@ -207,6 +213,7 @@ const dailySectionSchema = new Schema( date: { type: String, required: true }, content: { type: String, default: '' }, visibility: { type: String, enum: VISIBILITY_VALUES, default: 'private' }, + updatedAt: { type: Date }, }, { _id: false }, ); @@ -216,7 +223,7 @@ const relationshipNoteSchema = new Schema( otherInstanceId: { type: String, required: true }, notes: { type: String, default: '' }, visibility: { type: String, enum: VISIBILITY_VALUES, default: 'private' }, - updatedAt: { type: Date, default: Date.now }, + updatedAt: { type: Date }, }, { _id: false }, ); diff --git a/backend/routes/agentMemoryView.ts b/backend/routes/agentMemoryView.ts index 7f665c8e7..770e175e3 100644 --- a/backend/routes/agentMemoryView.ts +++ b/backend/routes/agentMemoryView.ts @@ -10,7 +10,8 @@ * - everyone else → 403 * * Returns snippets, never full raw content — "a fraction of memory". Internal - * housekeeping sections (dedup_state, runtime_meta) are excluded. + * housekeeping sections (dedup_state, runtime_meta) are excluded from snippets + * but may still be named as the most recent agent-authored write. */ // ESM import so CodeQL's js/missing-rate-limiting query sees the limiter. @@ -33,6 +34,7 @@ const Pod = require('../models/Pod'); const PGMessage = require('../models/pg/Message'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { resolveAgentDisplayLabel } = require('../services/agentIdentityService'); +const { getLastAgentMemoryWrite, BOOKKEEPING_SECTIONS } = require('../services/agentMemoryService'); interface AuthReq { user?: { id?: string }; @@ -51,8 +53,10 @@ const snip = (s: unknown): string => { return t.length > SNIPPET ? `${t.slice(0, SNIPPET)}…` : t; }; -// Sections that are internal housekeeping, never shown as "memory". -const INTERNAL_SECTIONS = new Set(['dedup_state', 'runtime_meta']); +// Sections that are internal housekeeping, never shown as "memory". Shared with +// the service so the public profile's durable/bookkeeping split and this +// exclusion cannot drift apart. +const INTERNAL_SECTIONS: ReadonlySet = BOOKKEEPING_SECTIONS; // Parse a markdown blob into an index of its `#`/`##` headers + the snippet of // text that follows each. Falls back to one untitled note if there are no @@ -172,7 +176,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { } const record = await AgentMemory.findOne({ agentName, instanceId }) - .select('sections updatedAt') + .select('sections') .lean(); const sections: Array<{ key: string; label: string; kind: string; notes: unknown[] }> = []; @@ -211,7 +215,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { instanceId, displayName: agentUser ? resolveAgentDisplayLabel(agentUser, agentUser.username) : instanceId, viewerRole: role, - updatedAt: (record as Record)?.updatedAt || null, + lastAgentWrite: getLastAgentMemoryWrite((record as Record)?.sections), totalEntries, sections, pods, diff --git a/backend/routes/agentProfile.ts b/backend/routes/agentProfile.ts index 92de49a91..ec68cab6d 100644 --- a/backend/routes/agentProfile.ts +++ b/backend/routes/agentProfile.ts @@ -20,7 +20,11 @@ // ESM import so CodeQL's js/missing-rate-limiting query sees the limiter. import rateLimit from 'express-rate-limit'; import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; -import { filterSectionsByVisibility } from '../services/agentMemoryService'; +import { + filterSectionsByVisibility, + getLastAgentMemoryWrite, + coarsenAgentMemoryWrite, +} from '../services/agentMemoryService'; // eslint-disable-next-line @typescript-eslint/no-require-imports const express = require('express'); @@ -155,19 +159,26 @@ router.get('/:agentName/:instanceId?', async (req: Req, res: Res) => { // Empty requester-pods ⇒ filterSectionsByVisibility returns ONLY public // sections. Never read record.content (the unfiltered v1 blob). // The profile is public, so it shows the memory LAYER as a stat (entry count - // + last-updated — safe, non-content) plus any explicitly-public sections. + // + the KIND and time of the last agent-authored write — safe, non-content, + // and deliberately not the section name) plus any explicitly-public + // sections. The envelope's updatedAt is not used: system writers bump it. // Entry counts reveal size, never content. Private/pod memory never leaks. let publicMemory: unknown = {}; let hasMemory = false; - let memoryUpdatedAt: unknown = null; + // Coarse kind, never the section name: this route is unauthenticated, and + // which housekeeping section a seat last touched is runtime detail rather + // than public identity. The owner/admin memory view keeps the exact section. + let lastAgentWrite: ReturnType = null; let memoryEntryCount = 0; const memRecord = await AgentMemory.findOne({ agentName, instanceId }) - .select('sections updatedAt') + .select('sections') .lean(); if (memRecord) { const sections = (memRecord as Record).sections; hasMemory = true; - memoryUpdatedAt = (memRecord as Record).updatedAt || null; + lastAgentWrite = coarsenAgentMemoryWrite(getLastAgentMemoryWrite( + sections as Parameters[0], + )); // Count entries across sections (size only — never content). for (const v of Object.values((sections || {}) as Record)) { if (Array.isArray(v)) memoryEntryCount += v.length; @@ -217,7 +228,7 @@ router.get('/:agentName/:instanceId?', async (req: Req, res: Res) => { memory: { has: hasMemory, entryCount: memoryEntryCount, - updatedAt: memoryUpdatedAt, + lastAgentWrite, sections: publicMemory, }, activity, diff --git a/backend/services/agentMemoryService.ts b/backend/services/agentMemoryService.ts index 4a0a77ee2..dc6abe4ab 100644 --- a/backend/services/agentMemoryService.ts +++ b/backend/services/agentMemoryService.ts @@ -167,8 +167,8 @@ export function mirrorContentFromSections( // preserves siblings, so siblings keep their previous stamp. // // Phase 1 array-section semantics (`daily`, `relationships`) are **whole-array -// replace**: sending `{ relationships: [...] }` replaces the entire stored -// array with the one in the payload, and every entry gets `updatedAt = now`. +// replace**: sending either array replaces the stored array, and every entry +// gets `updatedAt = now`. // This is consistent with the way the per-key dotted-$set merge in the PUT // handler stores arrays. A client that wants to add one entry must currently // resend all pre-existing entries. Phase 2's POST /memory/sync with explicit @@ -201,6 +201,7 @@ export function stampSectionsForWrite( date: d.date, content: d.content ?? '', visibility: (d.visibility ?? 'private') as MemoryVisibility, + updatedAt: now, })); continue; } @@ -225,6 +226,101 @@ export function stampSectionsForWrite( return out; } +export interface LastAgentMemoryWrite { + section: AgentWritableSection; + updatedAt: Date; +} + +// The envelope timestamp is deliberately NOT an activity signal: automatic +// system_exchanges writes update it even when the agent has saved no memory. +// Limit this view to the normal agent-save surface. `cycles` is likewise +// excluded: it is an agent journal, not one of the durable sections exposed by +// commonly_save_my_memory. Ties keep AGENT_WRITABLE_SECTIONS declaration +// order, which deliberately places long_term before bookkeeping sections. +// Legacy entries without a server stamp are skipped rather than assigned a +// fabricated time during hydration. +/** + * Sections that are internal housekeeping rather than authored memory. The + * memory view already excludes them from snippets; naming them is fine for an + * owner/admin, and is operational detail about the runtime on a public page. + */ +export const BOOKKEEPING_SECTIONS: ReadonlySet = new Set([ + 'dedup_state', + 'runtime_meta', +]); + +export type AgentWriteKind = 'durable' | 'bookkeeping'; + +export function classifyAgentWriteSection(section: AgentWritableSection): AgentWriteKind { + return BOOKKEEPING_SECTIONS.has(section) ? 'bookkeeping' : 'durable'; +} + +export interface LastAgentMemoryWriteKind { + kind: AgentWriteKind; + updatedAt: Date; +} + +/** + * One computation, two shapes. `/api/agents/:name/profile` is mounted WITHOUT + * auth, so it gets the coarse kind; the owner/admin memory view keeps the exact + * section. Coarsening here rather than at each caller keeps the two surfaces + * derived from the same max() rather than from two selection rules. + */ +export function coarsenAgentMemoryWrite( + write: LastAgentMemoryWrite | null, +): LastAgentMemoryWriteKind | null { + if (!write) return null; + return { kind: classifyAgentWriteSection(write.section), updatedAt: write.updatedAt }; +} + +export function getLastAgentMemoryWrite( + sections: IAgentMemorySections | undefined, +): LastAgentMemoryWrite | null { + let latest: LastAgentMemoryWrite | null = null; + const consider = (section: AgentWritableSection, value: unknown) => { + const updatedAt = value instanceof Date ? value : new Date(String(value || '')); + if (Number.isNaN(updatedAt.getTime())) return; + if (!latest) { + latest = { section, updatedAt }; + return; + } + if (updatedAt.getTime() > latest.updatedAt.getTime()) { + latest = { section, updatedAt }; + return; + } + // Exact ties are the common case, not the corner: one /memory/sync stamps + // every section it carries with the same `now`. Falling back to array order + // there would report `dedup_state` for a write that also saved `long_term`, + // which is the misreading this whole selection rule exists to prevent. So a + // durable section takes a tie from a bookkeeping one. Among two durables the + // winner is still array order and that is arbitrary — nothing may depend on + // it: the public surface coarsens both to `durable`, and the owner view is + // naming a section that genuinely holds content this recent either way. + if (updatedAt.getTime() === latest.updatedAt.getTime() + && classifyAgentWriteSection(latest.section) === 'bookkeeping' + && classifyAgentWriteSection(section) === 'durable') { + latest = { section, updatedAt }; + } + }; + + for (const section of AGENT_WRITABLE_SECTIONS) { + const value = sections?.[section]; + if (section === 'daily') { + for (const entry of (value as IDailySection[] | undefined) || []) { + consider(section, entry?.updatedAt); + } + } else if (section === 'relationships') { + for (const entry of (value as IRelationshipNote[] | undefined) || []) { + consider(section, entry?.updatedAt); + } + } else { + consider(section, (value as IMemorySection | undefined)?.updatedAt); + } + } + + return latest; +} + // GH#632 Tier-1 foundation: provenance + capped version history on section // writes. For each incoming BLOB section (soul / long_term / dedup_state / // shared — array sections are element-keyed and versioned separately later), diff --git a/docs/adr/ADR-003-memory-as-kernel-primitive.md b/docs/adr/ADR-003-memory-as-kernel-primitive.md index 654816cfc..061808be1 100644 --- a/docs/adr/ADR-003-memory-as-kernel-primitive.md +++ b/docs/adr/ADR-003-memory-as-kernel-primitive.md @@ -14,6 +14,7 @@ - **Phase 3 reframed driver-agnostic** (was "OpenClaw driver promotion"). Driver-side promotion is delegated to the per-driver ADRs: ADR-005 (local CLI wrapper) and ADR-006 (webhook SDK). OpenClaw's HEARTBEAT-template update, if done, is one OpenClaw-internal task among many, not a gate on other drivers. - **Kernel-coupling to OpenClaw deliberately removed** — drivers land via ADR-005 and ADR-006 alongside the existing OpenClaw driver, not ahead of it. - **2026-04-15 (Phase 3 §Deliverable 3 shipped, PR #199 → commit `720fc28e11`):** end-to-end proof that memory is kernel-shaped lives at `backend/__tests__/service/two-driver-memory-cross-check.test.js`. Two agents in one pod — one simulating the ADR-005 CLI-wrapper (`sourceRuntime: 'local-cli'`) and one simulating the ADR-006 Python SDK (`sourceRuntime: 'webhook-sdk-py'`) — each write and read their own envelope via `POST /memory/sync`. Seven tests: isolation, server stamps, patch, full, dedup, v1 mirror, cross-token scoping — all behave identically regardless of driver. +- **2026-08-26 (TASK-076):** per-seat memory activity is derived from the newest timestamp among `AGENT_WRITABLE_SECTIONS`, never the envelope's `updatedAt` (which system exchanges can advance). New agent-authored section writes receive a server-stamped `updatedAt`, so the metric can name the actual saved section without trusting client metadata. Legacy section records without that stamp are omitted rather than fabricated during hydration. --- @@ -109,7 +110,7 @@ interface AgentMemoryEnvelope { interface MemorySection { content: string; // markdown; opaque to Commonly visibility: 'private' | 'pod' | 'public'; // default 'private' - updatedAt: Date; + updatedAt?: Date; // server-stamped; absent on pre-TASK-076 records byteSize: number; // for quota } @@ -117,13 +118,14 @@ interface DailySection { date: string; // 'YYYY-MM-DD' content: string; visibility: 'private' | 'pod' | 'public'; + updatedAt?: Date; // server-stamped write time; absent on pre-TASK-076 entries } interface RelationshipNote { otherInstanceId: string; // peer agent or user id notes: string; // what I know / remember about them visibility: 'private' | 'pod' | 'public'; - updatedAt: Date; + updatedAt?: Date; // server-stamped; absent on pre-TASK-076 records } ``` diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index cdb59dff5..1bf1be37e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1294,11 +1294,24 @@ "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.", - "updatedClause": " · updated {{time}}", + "lastSavedClause": " · last saved to {{section}} {{time}}", "more": "+{{count}} more", "entriesRemembered": "entries remembered", "persistent": "Persistent memory carried across every pod and runtime this agent joins.", - "lastUpdated": "Last updated {{time}}.", + "lastSaved": "Last saved to {{section}} {{time}}.", + "kinds": { + "durable": "durable memory", + "bookkeeping": "internal bookkeeping" + }, + "sections": { + "soul": "Identity", + "long_term": "Long-term memory", + "dedup_state": "Deduplication state", + "shared": "Shared memory", + "runtime_meta": "Runtime metadata", + "daily": "Daily journal", + "relationships": "Relationships" + }, "none": "No memory recorded yet." }, "activity": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 88004bc70..52d82cfdb 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1288,11 +1288,24 @@ "viewTag": "{{role}}视图 · 私有", "indexSummary_one": "{{notes}} 条笔记,分布在 {{count}} 个板块{{updated}}。仅你可见;对公开资料页隐藏。", "indexSummary_other": "{{notes}} 条笔记,分布在 {{count}} 个板块{{updated}}。仅你可见;对公开资料页隐藏。", - "updatedClause": " · 更新于 {{time}}", + "lastSavedClause": " · 最后保存至 {{section}},{{time}}", "more": "还有 {{count}} 条", "entriesRemembered": "条已记住的条目", "persistent": "持久记忆会跟随该智能体,延续到它加入的每个 Pod 和运行时。", - "lastUpdated": "最后更新于 {{time}}。", + "lastSaved": "最后保存至 {{section}},{{time}}。", + "kinds": { + "durable": "长期记忆", + "bookkeeping": "内部记录" + }, + "sections": { + "soul": "身份", + "long_term": "长期记忆", + "dedup_state": "去重状态", + "shared": "共享记忆", + "runtime_meta": "运行时元数据", + "daily": "每日记录", + "relationships": "关系" + }, "none": "尚无记忆记录。" }, "activity": { diff --git a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx new file mode 100644 index 000000000..ed5276855 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import V2AgentProfile from '../agents/V2AgentProfile'; +import i18n, { i18nReady } from '../../i18n'; + +jest.mock('axios', () => { + const profileClient = { get: jest.fn() }; + return { + __esModule: true, + default: { create: jest.fn(() => profileClient) }, + __profileClient: profileClient, + }; +}); +jest.mock('../../utils/avatarUtils', () => ({ + presetCharacterOptions: jest.fn(() => []), +})); +jest.mock('../components/V2Avatar', () => ({ + __esModule: true, + default: () => null, +})); + +const profileClient = (jest.requireMock('axios') as { __profileClient: { get: jest.Mock } }).__profileClient; + +const renderProfile = () => render( + + + } /> + + , +); + +// The profile route is unauthenticated, so it reports the KIND of the latest +// agent-authored write and never the section name. The owner/admin memory view +// keeps the exact section; that surface is covered separately. +const profilePayload = (lastAgentWrite: unknown) => ({ + data: { + agent: { + agentName: 'claude-code', + instanceId: 'observer', + displayName: 'Observer', + profilePicture: 'default', + runtime: null, + officialAgent: false, + capabilities: [], + }, + skills: [], + pods: { count: 0, public: [] }, + memory: { has: true, entryCount: 2, lastAgentWrite }, + activity: [], + }, +}); +const twoHoursAgo = () => new Date(Date.now() - (2 * 60 * 60 * 1000)).toISOString(); + +describe('V2AgentProfile memory-write visibility', () => { + beforeAll(async () => { + await i18nReady; + await i18n.changeLanguage('en'); + }); + + beforeEach(() => { + window.localStorage.clear(); + jest.clearAllMocks(); + profileClient.get.mockResolvedValue( + profilePayload({ kind: 'durable', updatedAt: twoHoursAgo() }), + ); + }); + + it('reports the kind of the latest agent-authored write', async () => { + renderProfile(); + + expect(await screen.findByText(/Last saved to durable memory \d+h ago\./)).toBeInTheDocument(); + }); + + it('reports a housekeeping write without naming the section', async () => { + profileClient.get.mockResolvedValue( + profilePayload({ kind: 'bookkeeping', updatedAt: twoHoursAgo() }), + ); + renderProfile(); + + expect(await screen.findByText(/Last saved to internal bookkeeping \d+h ago\./)).toBeInTheDocument(); + // No section name reaches this page for any kind. + expect(screen.queryByText(/Runtime metadata|Deduplication state|Long-term memory/)).toBeNull(); + }); +}); diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx index 66b66ae5f..6ada94cdd 100644 --- a/frontend/src/v2/agents/V2AgentProfile.tsx +++ b/frontend/src/v2/agents/V2AgentProfile.tsx @@ -41,7 +41,13 @@ interface AgentProfile { }; skills: Array<{ name: string; description?: string }>; pods: { count: number; public: Array<{ id: string; name: string; lastActive?: string | null }> }; - memory: { has: boolean; entryCount: number; updatedAt?: string | null }; + memory: { + has: boolean; + entryCount: number; + // Public profile: coarse kind only. The owner/admin memory index below + // carries the exact section. + lastAgentWrite?: { kind: 'durable' | 'bookkeeping'; updatedAt: string } | null; + }; activity: Array<{ status: string; trigger?: string; startedAt?: string; turns: number; errorKind?: string }>; } @@ -57,7 +63,7 @@ interface PodEntry { interface MemoryIndex { viewerRole: 'owner' | 'admin'; totalEntries: number; - updatedAt?: string | null; + lastAgentWrite?: { section: string; updatedAt: string } | null; sections: Array<{ key: string; label: string; kind: string; notes: Array<{ header: string; snippet: string }> }>; pods?: PodEntry[]; } @@ -232,6 +238,14 @@ const V2AgentProfile: React.FC = () => { const runtimeLabel = (agent.runtime || '').toUpperCase(); const authed = isAuthed(); const firstName = agent.displayName.split(' ')[0]; + const memorySectionLabel = (section: string) => t( + `agentProfile.memory.sections.${section}`, + { defaultValue: section }, + ); + const memoryKindLabel = (kind: string) => t( + `agentProfile.memory.kinds.${kind}`, + { defaultValue: kind }, + ); return (
@@ -380,8 +394,11 @@ const V2AgentProfile: React.FC = () => { {t('agentProfile.memory.indexSummary', { notes: memIndex.totalEntries, count: memIndex.sections.length, - updated: memIndex.updatedAt - ? t('agentProfile.memory.updatedClause', { time: timeAgo(memIndex.updatedAt) }) + updated: memIndex.lastAgentWrite + ? t('agentProfile.memory.lastSavedClause', { + section: memorySectionLabel(memIndex.lastAgentWrite.section), + time: timeAgo(memIndex.lastAgentWrite.updatedAt), + }) : '', })}

@@ -410,7 +427,10 @@ const V2AgentProfile: React.FC = () => {

{t('agentProfile.memory.persistent')} - {memory.updatedAt && ` ${t('agentProfile.memory.lastUpdated', { time: timeAgo(memory.updatedAt) })}`} + {memory.lastAgentWrite && ` ${t('agentProfile.memory.lastSaved', { + section: memoryKindLabel(memory.lastAgentWrite.kind), + time: timeAgo(memory.lastAgentWrite.updatedAt), + })}`}

) : (