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:
@@ -0,0 +1,278 @@
|
||||
const MS_PER_HOUR = 3600000;
|
||||
const MS_PER_DAY = 86400000;
|
||||
|
||||
const FALSE_STORE_PATTERNS = [
|
||||
{
|
||||
code: 'question_mark',
|
||||
pattern: /[??]\s*$/,
|
||||
message: 'Content ends with a question mark',
|
||||
},
|
||||
{
|
||||
code: 'what_is_question',
|
||||
pattern: /^(?:什么是|解释一下|介绍一下|请问|能不能|是否可以|怎么|如何|为什么|啥是)/u,
|
||||
message: 'Content looks like a question rather than a fact',
|
||||
},
|
||||
{
|
||||
code: 'imperative_only',
|
||||
pattern: /^(?:查一下|搜一下|看看|继续|下一步|帮我|请帮我)/u,
|
||||
message: 'Content looks like an instruction without durable facts',
|
||||
},
|
||||
{
|
||||
code: 'trivial_greeting',
|
||||
pattern: /^(?:你好|您好|谢谢|好的|可以|收到|再见|hi|hello|thanks)[!!。.\s]*$/iu,
|
||||
message: 'Content is a trivial greeting or acknowledgement',
|
||||
},
|
||||
{
|
||||
code: 'routing_leak',
|
||||
pattern: /(?:\[Memory Context\]|【Memind 任务编排】|agent_orchestration)/u,
|
||||
message: 'Content contains internal routing or memory envelope text',
|
||||
},
|
||||
];
|
||||
|
||||
export function parseSinceArg(value, now = Date.now()) {
|
||||
const raw = String(value ?? '7d').trim().toLowerCase();
|
||||
const match = raw.match(/^(\d+)(h|d|w)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid --since value "${value}". Expected formats like 24h, 7d, 2w.`);
|
||||
}
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error(`Invalid --since value "${value}". Amount must be positive.`);
|
||||
}
|
||||
const unit = match[2];
|
||||
const multiplier = unit === 'h' ? MS_PER_HOUR : unit === 'w' ? MS_PER_DAY * 7 : MS_PER_DAY;
|
||||
return {
|
||||
sinceMs: now - amount * multiplier,
|
||||
label: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function detectFalseStoreCandidate(content) {
|
||||
const text = String(content ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) {
|
||||
return { suspicious: true, code: 'empty_content', message: 'Content is empty' };
|
||||
}
|
||||
for (const rule of FALSE_STORE_PATTERNS) {
|
||||
if (rule.pattern.test(text)) {
|
||||
return { suspicious: true, code: rule.code, message: rule.message };
|
||||
}
|
||||
}
|
||||
return { suspicious: false };
|
||||
}
|
||||
|
||||
function normalizeCandidate(row) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.user_id ?? row.userId),
|
||||
sessionId: row.session_id == null ? null : String(row.session_id ?? row.sessionId),
|
||||
memoryType: String(row.memory_type ?? row.memoryType ?? ''),
|
||||
content: String(row.content ?? ''),
|
||||
status: String(row.status ?? ''),
|
||||
policyReason: String(row.policy_reason ?? row.policyReason ?? ''),
|
||||
confidence: Number(row.confidence ?? 0),
|
||||
importance: Number(row.importance ?? 0),
|
||||
createdAt: Number(row.created_at ?? row.createdAt ?? 0),
|
||||
updatedAt: Number(row.updated_at ?? row.updatedAt ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMemoryItem(row) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.user_id ?? row.userId),
|
||||
label: String(row.label ?? ''),
|
||||
content: String(row.memory_text ?? row.content ?? ''),
|
||||
status: String(row.status ?? ''),
|
||||
createdAt: Number(row.created_at ?? row.createdAt ?? 0),
|
||||
updatedAt: Number(row.updated_at ?? row.updatedAt ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function countByField(items, field) {
|
||||
const counts = new Map();
|
||||
for (const item of items) {
|
||||
const key = String(item[field] ?? 'unknown');
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return Object.fromEntries([...counts.entries()].sort((a, b) => b[1] - a[1]));
|
||||
}
|
||||
|
||||
function topEntries(counts, limit = 10) {
|
||||
return Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit)
|
||||
.map(([key, count]) => ({ key, count }));
|
||||
}
|
||||
|
||||
export function auditMemoryV2Shadow({
|
||||
candidates = [],
|
||||
memoryItems = [],
|
||||
pgvectorMemoryIds = new Set(),
|
||||
pgvectorConfigured = false,
|
||||
agentMemoryEvents = [],
|
||||
sinceMs = 0,
|
||||
nowMs = Date.now(),
|
||||
falseStoreSampleLimit = 20,
|
||||
pgvectorLagSampleLimit = 20,
|
||||
} = {}) {
|
||||
const normalizedCandidates = candidates.map(normalizeCandidate);
|
||||
const normalizedItems = memoryItems.map(normalizeMemoryItem);
|
||||
const inWindowCandidates = normalizedCandidates.filter((item) => item.createdAt >= sinceMs);
|
||||
const inWindowItems = normalizedItems.filter((item) => item.updatedAt >= sinceMs);
|
||||
|
||||
const candidateStatusCounts = countByField(inWindowCandidates, 'status');
|
||||
const policyReasonCounts = countByField(inWindowCandidates, 'policyReason');
|
||||
const memoryTypeCounts = countByField(inWindowCandidates, 'memoryType');
|
||||
|
||||
const suspiciousCandidates = inWindowCandidates
|
||||
.filter((item) => ['candidate', 'accepted'].includes(item.status))
|
||||
.map((item) => {
|
||||
const verdict = detectFalseStoreCandidate(item.content);
|
||||
if (!verdict.suspicious) return null;
|
||||
return {
|
||||
id: item.id,
|
||||
userId: item.userId,
|
||||
status: item.status,
|
||||
policyReason: item.policyReason,
|
||||
code: verdict.code,
|
||||
message: verdict.message,
|
||||
contentPreview: item.content.slice(0, 120),
|
||||
createdAt: item.createdAt,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const activeItems = normalizedItems.filter((item) => item.status === 'active');
|
||||
const pgvectorLagUsers = new Map();
|
||||
const pgvectorMissingSamples = [];
|
||||
if (pgvectorConfigured) {
|
||||
for (const item of activeItems) {
|
||||
if (pgvectorMemoryIds.has(item.id)) continue;
|
||||
pgvectorLagUsers.set(item.userId, (pgvectorLagUsers.get(item.userId) ?? 0) + 1);
|
||||
if (pgvectorMissingSamples.length < pgvectorLagSampleLimit) {
|
||||
pgvectorMissingSamples.push({
|
||||
memoryId: item.id,
|
||||
userId: item.userId,
|
||||
label: item.label,
|
||||
updatedAt: item.updatedAt,
|
||||
contentPreview: item.content.slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedEvents = agentMemoryEvents.filter((event) => {
|
||||
const createdAt = Number(event.created_at ?? event.createdAt ?? 0);
|
||||
return createdAt >= sinceMs;
|
||||
});
|
||||
const resolvedWithHits = resolvedEvents.filter((event) => {
|
||||
const data = event.data_json ?? event.data ?? {};
|
||||
const count = Number(data.count ?? data.memoryCount ?? data.memories?.length ?? 0);
|
||||
return count > 0;
|
||||
});
|
||||
|
||||
const acceptedCount = candidateStatusCounts.accepted ?? 0;
|
||||
const candidateCount = candidateStatusCounts.candidate ?? 0;
|
||||
const reviewedCount = acceptedCount + (candidateStatusCounts.rejected ?? 0);
|
||||
const autoAcceptRate = reviewedCount > 0 ? acceptedCount / reviewedCount : null;
|
||||
const falseStoreRate = inWindowCandidates.length > 0
|
||||
? suspiciousCandidates.length / inWindowCandidates.length
|
||||
: null;
|
||||
const resolveHitRate = resolvedEvents.length > 0
|
||||
? resolvedWithHits.length / resolvedEvents.length
|
||||
: null;
|
||||
|
||||
const thresholds = {
|
||||
falseStoreRateMax: 0.05,
|
||||
pgvectorLagUsersMax: 0,
|
||||
};
|
||||
|
||||
const issues = [];
|
||||
if (falseStoreRate != null && falseStoreRate > thresholds.falseStoreRateMax) {
|
||||
issues.push({
|
||||
code: 'false_store_rate_high',
|
||||
message: `False-store sample rate ${(falseStoreRate * 100).toFixed(1)}% exceeds ${thresholds.falseStoreRateMax * 100}%`,
|
||||
});
|
||||
}
|
||||
if (pgvectorConfigured && pgvectorLagUsers.size > thresholds.pgvectorLagUsersMax) {
|
||||
issues.push({
|
||||
code: 'pgvector_sync_lag',
|
||||
message: `${pgvectorLagUsers.size} user(s) have active memories missing from pgvector`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: issues.length === 0,
|
||||
generatedAt: nowMs,
|
||||
window: {
|
||||
sinceMs,
|
||||
untilMs: nowMs,
|
||||
candidateCount: inWindowCandidates.length,
|
||||
memoryItemCount: inWindowItems.length,
|
||||
agentMemoryResolvedEvents: resolvedEvents.length,
|
||||
},
|
||||
summary: {
|
||||
candidateStatusCounts,
|
||||
policyReasonCounts,
|
||||
memoryTypeCounts,
|
||||
autoAcceptRate,
|
||||
falseStoreRate,
|
||||
resolveHitRate,
|
||||
suspiciousCandidateCount: suspiciousCandidates.length,
|
||||
pgvectorLagUserCount: pgvectorConfigured ? pgvectorLagUsers.size : null,
|
||||
activeMemoryCount: activeItems.length,
|
||||
pgvectorMemoryCount: pgvectorConfigured ? pgvectorMemoryIds.size : null,
|
||||
},
|
||||
topPolicyReasons: topEntries(policyReasonCounts),
|
||||
pgvectorLagUsers: [...pgvectorLagUsers.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, pgvectorLagSampleLimit)
|
||||
.map(([userId, missingCount]) => ({ userId, missingCount })),
|
||||
sampleFalseStores: suspiciousCandidates.slice(0, falseStoreSampleLimit),
|
||||
pgvectorMissingSamples,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMemoryV2ShadowAuditReport(report) {
|
||||
const lines = [
|
||||
`memory v2 shadow audit: ${report.ok ? 'ok' : 'issues found'}`,
|
||||
`window: ${new Date(report.window.sinceMs).toISOString()} -> ${new Date(report.window.untilMs).toISOString()}`,
|
||||
`candidates: ${report.window.candidateCount}`,
|
||||
`memory items updated: ${report.window.memoryItemCount}`,
|
||||
`agent_memory_resolved events: ${report.window.agentMemoryResolvedEvents}`,
|
||||
'',
|
||||
'summary:',
|
||||
JSON.stringify(report.summary, null, 2),
|
||||
];
|
||||
|
||||
if (report.topPolicyReasons.length > 0) {
|
||||
lines.push('', 'top policy reasons:');
|
||||
for (const item of report.topPolicyReasons) {
|
||||
lines.push(`- ${item.key}: ${item.count}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (report.pgvectorLagUsers.length > 0) {
|
||||
lines.push('', 'pgvector lag users:');
|
||||
for (const item of report.pgvectorLagUsers) {
|
||||
lines.push(`- ${item.userId}: ${item.missingCount} missing`);
|
||||
}
|
||||
}
|
||||
|
||||
if (report.sampleFalseStores.length > 0) {
|
||||
lines.push('', 'sample false stores:');
|
||||
for (const item of report.sampleFalseStores) {
|
||||
lines.push(`- [${item.code}] ${item.id} (${item.policyReason}): ${item.contentPreview}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (report.issues.length > 0) {
|
||||
lines.push('', 'issues:');
|
||||
for (const item of report.issues) {
|
||||
lines.push(`- ${item.code}: ${item.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user