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,186 @@
|
||||
import { detectFalseStoreCandidate } from './memory-v2-shadow-audit.mjs';
|
||||
import {
|
||||
inspectMemoryContentDurability,
|
||||
resolvePersonalShadowConfig,
|
||||
} from './memory-v2-personal-shadow.mjs';
|
||||
import { createPersonalMemoryCandidateStore } from './memory-v2-personal-store.mjs';
|
||||
|
||||
const AUTO_REVIEW_ACCEPT_REASONS = new Set([
|
||||
'explicit_memory_request',
|
||||
'preference_signal',
|
||||
'goal_signal',
|
||||
'stable_fact_signal',
|
||||
]);
|
||||
|
||||
const AUTO_REVIEW_REJECT_REASONS = new Set([
|
||||
'decision_signal',
|
||||
]);
|
||||
|
||||
export function resolveCandidateAutoReviewAction(candidate, { mode = 'canary' } = {}) {
|
||||
const normalizedMode = String(mode ?? 'canary').trim().toLowerCase();
|
||||
if (normalizedMode === 'off' || normalizedMode === 'shadow') {
|
||||
return { action: 'pending', reason: 'shadow_mode' };
|
||||
}
|
||||
if (!candidate || String(candidate.status ?? 'candidate') !== 'candidate') {
|
||||
return { action: 'pending', reason: 'not_pending' };
|
||||
}
|
||||
|
||||
const content = String(candidate.content ?? '');
|
||||
const durabilityIssue = inspectMemoryContentDurability(content);
|
||||
if (durabilityIssue) {
|
||||
return { action: 'reject', reason: durabilityIssue };
|
||||
}
|
||||
|
||||
const falseStore = detectFalseStoreCandidate(content);
|
||||
if (falseStore.suspicious) {
|
||||
return { action: 'reject', reason: falseStore.code ?? 'false_store' };
|
||||
}
|
||||
|
||||
const policyReason = String(candidate.policyReason ?? '');
|
||||
if (AUTO_REVIEW_REJECT_REASONS.has(policyReason)) {
|
||||
return { action: 'reject', reason: 'decision_signal_not_durable' };
|
||||
}
|
||||
if (AUTO_REVIEW_ACCEPT_REASONS.has(policyReason)) {
|
||||
return { action: 'accept', reason: 'auto_review_policy' };
|
||||
}
|
||||
|
||||
return { action: 'pending', reason: 'no_auto_review_rule' };
|
||||
}
|
||||
|
||||
export function resolveCandidateAutoReviewScopes(env = process.env) {
|
||||
const config = resolvePersonalShadowConfig(env);
|
||||
if (!config.enabled || config.effectiveMode === 'shadow') return [];
|
||||
if (config.effectiveMode === 'active') return [null];
|
||||
|
||||
const rolloutMode = String(env.MEMORY_LIFECYCLE_ROLLOUT_MODE ?? 'off').trim().toLowerCase();
|
||||
const rolloutUserIds = String(env.MEMORY_LIFECYCLE_ROLLOUT_USER_IDS ?? '')
|
||||
.split(/[\s,]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 1000);
|
||||
|
||||
if (rolloutMode === 'canary' && rolloutUserIds.length > 0) {
|
||||
return [...new Set(rolloutUserIds)];
|
||||
}
|
||||
if (config.effectiveMode === 'canary') {
|
||||
const agentCanaryUserIds = String(env.MEMORY_AGENT_CANARY_USER_IDS ?? '')
|
||||
.split(/[\s,]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 1000);
|
||||
if (agentCanaryUserIds.length > 0) return [...new Set(agentCanaryUserIds)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function runCandidateAutoReviewBatch(
|
||||
pool,
|
||||
{
|
||||
env = process.env,
|
||||
userId = null,
|
||||
limit = 100,
|
||||
reviewedBy = 'system:auto-review',
|
||||
dryRun = false,
|
||||
now = () => Date.now(),
|
||||
} = {},
|
||||
) {
|
||||
if (!pool?.query) {
|
||||
return { ok: false, skipped: true, reason: 'no_pool', accepted: 0, rejected: 0, pending: 0 };
|
||||
}
|
||||
|
||||
const config = resolvePersonalShadowConfig(env);
|
||||
if (!config.autoReviewEnabled) {
|
||||
return { ok: true, skipped: true, reason: 'auto_review_disabled', accepted: 0, rejected: 0, pending: 0 };
|
||||
}
|
||||
|
||||
const store = createPersonalMemoryCandidateStore(pool, { now });
|
||||
if (!store?.listCandidates || !store?.reviewCandidate) {
|
||||
return { ok: false, skipped: true, reason: 'no_store', accepted: 0, rejected: 0, pending: 0 };
|
||||
}
|
||||
|
||||
const items = await store.listCandidates({
|
||||
status: 'candidate',
|
||||
userId,
|
||||
limit: Math.max(1, Math.min(500, Number(limit) || 100)),
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
let accepted = 0;
|
||||
let rejected = 0;
|
||||
let pending = 0;
|
||||
const samples = [];
|
||||
|
||||
for (const item of items) {
|
||||
const decision = resolveCandidateAutoReviewAction(item, { mode: config.effectiveMode });
|
||||
if (decision.action === 'accept') {
|
||||
if (dryRun) {
|
||||
accepted += 1;
|
||||
if (samples.length < 5) samples.push({ id: item.id, action: 'accepted', reason: decision.reason });
|
||||
continue;
|
||||
}
|
||||
const result = await store.reviewCandidate(item.id, 'accepted', { reviewedBy });
|
||||
if (result.updated) {
|
||||
accepted += 1;
|
||||
if (samples.length < 5) samples.push({ id: item.id, action: 'accepted', reason: decision.reason });
|
||||
} else {
|
||||
pending += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (decision.action === 'reject') {
|
||||
if (dryRun) {
|
||||
rejected += 1;
|
||||
if (samples.length < 5) samples.push({ id: item.id, action: 'rejected', reason: decision.reason });
|
||||
continue;
|
||||
}
|
||||
const result = await store.reviewCandidate(item.id, 'rejected', { reviewedBy });
|
||||
if (result.updated) {
|
||||
rejected += 1;
|
||||
if (samples.length < 5) samples.push({ id: item.id, action: 'rejected', reason: decision.reason });
|
||||
} else {
|
||||
pending += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pending += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
dryRun,
|
||||
mode: config.effectiveMode,
|
||||
scanned: items.length,
|
||||
accepted,
|
||||
rejected,
|
||||
pending,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
export async function autoReviewCandidateIfPending(
|
||||
pool,
|
||||
candidate,
|
||||
{
|
||||
env = process.env,
|
||||
reviewedBy = 'system:auto-review',
|
||||
now = () => Date.now(),
|
||||
} = {},
|
||||
) {
|
||||
if (!pool?.query || !candidate?.id || String(candidate.status ?? '') !== 'candidate') {
|
||||
return { reviewed: false, reason: 'not_pending' };
|
||||
}
|
||||
const config = resolvePersonalShadowConfig(env);
|
||||
const decision = resolveCandidateAutoReviewAction(candidate, { mode: config.effectiveMode });
|
||||
if (decision.action !== 'accept' && decision.action !== 'reject') {
|
||||
return { reviewed: false, reason: decision.reason };
|
||||
}
|
||||
const store = createPersonalMemoryCandidateStore(pool, { now });
|
||||
const status = decision.action === 'accept' ? 'accepted' : 'rejected';
|
||||
const result = await store.reviewCandidate(candidate.id, status, { reviewedBy });
|
||||
return {
|
||||
reviewed: Boolean(result.updated),
|
||||
status,
|
||||
reason: decision.reason,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user