feat(memory-v2): close Phase A with auto-review, product events, and H5 recall UI.

Add candidate auto-review pipeline, shadow audit tooling, admin metrics page,
and user-visible memory recall hints in chat with phase-a readiness checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 17:14:06 +08:00
parent 666db0b939
commit 6f3e53a56a
50 changed files with 3730 additions and 38 deletions
+4
View File
@@ -36,6 +36,8 @@ export function attachPortalAgentRuntimeRoutes(
getTkmindProxy = () => null,
getLlmProviderService = () => null,
getAgentRunGateway = () => null,
getGoalRunService = () => null,
getChatIntentRouter = () => null,
getSessionAccess = () => null,
getMindSpaceAssetAgent = () => null,
getCodeRunPolicyService = () => null,
@@ -130,6 +132,8 @@ export function attachPortalAgentRuntimeRoutes(
agentRunGateway,
mindSpaceAssetAgent: getMindSpaceAssetAgent(),
codeRunPolicyService: getCodeRunPolicyService(),
goalRunService: getGoalRunService(),
chatIntentRouter: getChatIntentRouter(),
}),
],
req,
+108
View File
@@ -1,3 +1,5 @@
import { summarizePersonalMemoryObservation } from '../memory-v2-user-feedback.mjs';
function assertRouter(api) {
if (
!api ||
@@ -11,11 +13,84 @@ function assertRouter(api) {
}
}
function parseRecallEventData(raw) {
if (!raw) return null;
if (typeof raw === 'object') return raw;
try {
return JSON.parse(String(raw));
} catch {
return null;
}
}
export async function resolveLatestMemoryRecallHint(pool, { userId, sessionId }) {
if (!pool?.query || !userId || !sessionId) {
return {
memoryCount: 0,
injectionEnabled: false,
mode: null,
createdAt: null,
savedPreview: null,
memoryPreviews: [],
};
}
const [rows] = await pool.query(
`SELECT e.data_json, e.created_at, r.id AS run_id
FROM h5_agent_run_events e
INNER JOIN h5_agent_runs r ON r.id = e.run_id
WHERE r.user_id = ?
AND r.agent_session_id = ?
AND e.event_type = 'agent_memory_resolved'
ORDER BY e.created_at DESC
LIMIT 1`,
[String(userId), String(sessionId)],
);
const [candidateRows] = await pool.query(
`SELECT content, updated_at
FROM h5_memory_v2_candidates
WHERE user_id = ?
AND session_id = ?
AND status = 'accepted'
ORDER BY updated_at DESC
LIMIT 1`,
[String(userId), String(sessionId)],
);
const row = rows[0];
const candidate = candidateRows[0];
if (!row) {
return {
memoryCount: 0,
injectionEnabled: false,
mode: null,
createdAt: null,
runId: null,
savedPreview: candidate?.content ? String(candidate.content).slice(0, 60) : null,
savedAt: candidate?.updated_at == null ? null : Number(candidate.updated_at),
memoryPreviews: [],
};
}
const data = parseRecallEventData(row.data_json);
const memoryPreviews = Array.isArray(data?.memoryPreviews)
? data.memoryPreviews.map((item) => String(item).slice(0, 120)).filter(Boolean).slice(0, 5)
: [];
return {
memoryCount: Number(data?.memoryCount ?? data?.count ?? 0) || 0,
injectionEnabled: Boolean(data?.injectionEnabled),
mode: data?.mode == null ? null : String(data.mode),
createdAt: Number(row.created_at ?? 0) || null,
runId: row.run_id == null ? null : String(row.run_id),
savedPreview: candidate?.content ? String(candidate.content).slice(0, 60) : null,
savedAt: candidate?.updated_at == null ? null : Number(candidate.updated_at),
memoryPreviews,
};
}
export function attachPortalUserMemoryRoutes(
api,
{
getMemoryV2 = () => null,
getTkmindProxy = () => null,
getPool = () => null,
ensureUserMemoryCapability = async () => null,
ownsAgentSession = async () => false,
loadUserVisibleConversation = async () => [],
@@ -64,12 +139,14 @@ export function attachPortalUserMemoryRoutes(
sessionId,
limit: 200,
});
const personalMemory = summarizePersonalMemoryObservation(result.personalMemory);
return res.json({
ok: true,
analyzed: result.analyzed ?? 0,
memories: result.memories ?? 0,
totalMemories: memories.length,
syncedToSession,
personalMemory,
});
} catch (err) {
return res.status(500).json({
@@ -78,6 +155,37 @@ export function attachPortalUserMemoryRoutes(
}
});
api.get('/user-memory/v1/recall-hint', async (req, res) => {
const memoryV2 = getMemoryV2();
const memoryStatus = await memoryV2?.getStatus?.().catch(() => null);
if (!memoryStatus?.enabled) {
return res.status(503).json({ message: '长期记忆功能未启用' });
}
const capabilityState = await ensureUserMemoryCapability(req, res);
if (!capabilityState) return;
const sessionId = String(req.query?.sessionId ?? '').trim();
if (!sessionId) {
return res.status(400).json({ message: '缺少 sessionId' });
}
const owns = await ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const hint = await resolveLatestMemoryRecallHint(getPool(), {
userId: req.currentUser.id,
sessionId,
});
return res.json({ ok: true, ...hint });
} catch (err) {
return res.status(500).json({
message: err instanceof Error ? err.message : '读取记忆召回提示失败',
});
}
});
api.post('/user-memory/v1/sync', async (req, res) => {
const memoryV2 = getMemoryV2();
const memoryStatus = await memoryV2?.getStatus?.().catch(() => null);
+83 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { attachPortalUserMemoryRoutes } from './portal-user-memory-routes.mjs';
import { attachPortalUserMemoryRoutes, resolveLatestMemoryRecallHint } from './portal-user-memory-routes.mjs';
function createRouterRecorder() {
const routes = new Map();
@@ -69,6 +69,7 @@ test('User Memory module preserves route inventory and order', () => {
assert.deepEqual([...api.routes.keys()], [
'POST /user-memory/v1/remember-recent',
'GET /user-memory/v1/recall-hint',
'POST /user-memory/v1/sync',
'GET /user-memory/v1/items',
'DELETE /user-memory/v1/items/:memoryId',
@@ -212,6 +213,11 @@ test('remember-recent preserves write, sync, resolve order and response projecti
memories: 2,
totalMemories: 3,
syncedToSession: true,
personalMemory: {
savedPreviews: [],
autoReviewed: 0,
pendingReview: 0,
},
});
});
@@ -419,3 +425,79 @@ test('delete preserves forwarding, unavailable fallback, success, and errors', a
assert.equal(failureRes.statusCode, 500);
assert.deepEqual(failureRes.body, { message: 'forget failed' });
});
test('resolveLatestMemoryRecallHint merges recall event and saved candidate preview', async () => {
const pool = {
async query(sql, params) {
if (String(sql).includes('agent_memory_resolved')) {
return [[{
data_json: JSON.stringify({
memoryCount: 3,
injectionEnabled: true,
mode: 'canary',
memoryPreviews: ['我喜欢喝美式咖啡', '每周三做代码评审'],
}),
created_at: 1000,
run_id: 'run-1',
}]];
}
if (String(sql).includes('h5_memory_v2_candidates')) {
assert.deepEqual(params, ['user-1', 'session-1']);
return [[{ content: '我喜欢喝美式咖啡', updated_at: 900 }]];
}
return [[]];
},
};
const hint = await resolveLatestMemoryRecallHint(pool, {
userId: 'user-1',
sessionId: 'session-1',
});
assert.deepEqual(hint, {
memoryCount: 3,
injectionEnabled: true,
mode: 'canary',
createdAt: 1000,
runId: 'run-1',
savedPreview: '我喜欢喝美式咖啡',
savedAt: 900,
memoryPreviews: ['我喜欢喝美式咖啡', '每周三做代码评审'],
});
});
test('recall-hint preserves capability gate and returns merged hint payload', async () => {
const api = createRouterRecorder();
attachPortalUserMemoryRoutes(
api,
createDependencies({
getPool: () => ({
async query(sql) {
if (String(sql).includes('agent_memory_resolved')) {
return [[{
data_json: JSON.stringify({ memoryCount: 2, injectionEnabled: true, mode: 'canary' }),
created_at: 2000,
run_id: 'run-9',
}]];
}
return [[]];
},
}),
}),
);
const res = createResponseRecorder();
await api.routes.get('GET /user-memory/v1/recall-hint')(
createRequest({ query: { sessionId: 'session-9' } }),
res,
);
assert.equal(res.statusCode, 200);
assert.deepEqual(res.body, {
ok: true,
memoryCount: 2,
injectionEnabled: true,
mode: 'canary',
createdAt: 2000,
runId: 'run-9',
savedPreview: null,
savedAt: null,
memoryPreviews: [],
});
});