feat(memory-v2): auto-accept candidate memories in active and canary modes

Let Personal Memory candidates pass policy gates without per-user manual review, while keeping shadow mode for explicit human approval.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-13 14:16:57 +08:00
parent 7f7aced751
commit b33c943b69
4 changed files with 147 additions and 13 deletions
+43 -5
View File
@@ -70,13 +70,34 @@ function classify(text) {
return rules.find((rule) => rule.pattern.test(text)) ?? null;
}
const CANDIDATE_MODES = new Set(['off', 'shadow', 'canary', 'active']);
function normalizeCandidateMode(value, fallback = 'shadow') {
const normalized = normalizeText(value, 20).toLowerCase();
return CANDIDATE_MODES.has(normalized) ? normalized : fallback;
}
export function shouldAutoAcceptCandidate(candidate, config) {
if (!candidate || !config?.enabled) return false;
const mode = normalizeCandidateMode(config.requestedMode, 'shadow');
if (mode === 'shadow' || mode === 'off') return false;
if (mode === 'active') return true;
if (candidate.policyReason === 'explicit_memory_request') return true;
return Number(candidate.confidence) >= Math.max(Number(config.minConfidence) || 0, 0.9);
}
export function resolvePersonalShadowConfig(env = process.env) {
const candidateEnabled = readFlag(env, 'MEMORY_CANDIDATE_ENABLED', false);
const requestedMode = normalizeText(env?.MEMORY_CANDIDATE_MODE || (candidateEnabled ? 'shadow' : 'off'), 20).toLowerCase();
const requestedMode = normalizeCandidateMode(
env?.MEMORY_CANDIDATE_MODE || (candidateEnabled ? 'active' : 'off'),
candidateEnabled ? 'active' : 'off',
);
const enabled = candidateEnabled && requestedMode !== 'off';
return {
enabled: candidateEnabled && requestedMode !== 'off',
enabled,
requestedMode,
effectiveMode: candidateEnabled && requestedMode !== 'off' ? 'shadow' : 'off',
effectiveMode: enabled ? requestedMode : 'off',
autoReviewEnabled: enabled && requestedMode !== 'shadow',
minImportance: readNumber(env, 'MEMORY_CANDIDATE_MIN_IMPORTANCE', 0.7, { min: 0, max: 1 }),
minConfidence: readNumber(env, 'MEMORY_CANDIDATE_MIN_CONFIDENCE', 0.8, { min: 0, max: 1 }),
maxPending: Math.round(readNumber(env, 'MEMORY_CANDIDATE_MAX_PENDING', 500, { min: 1, max: 5000 })),
@@ -96,6 +117,8 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
runs: 0,
observedMessages: 0,
accepted: 0,
autoReviewed: 0,
pendingReview: 0,
rejected: 0,
deduped: 0,
errors: 0,
@@ -178,10 +201,21 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
metrics.observedMessages += 1;
const result = evaluate({ userId, sessionId, message, index });
if (result.accepted && store?.saveCandidate) {
const persisted = await store.saveCandidate(result.candidate);
const autoAccept = shouldAutoAcceptCandidate(result.candidate, config);
const persisted = await store.saveCandidate(result.candidate, { autoAccept });
if (persisted?.inserted === false) {
metrics.deduped += 1;
} else if (autoAccept) {
result.candidate.status = 'accepted';
metrics.autoReviewed += 1;
} else {
metrics.pendingReview += 1;
}
} else if (result.accepted) {
const autoAccept = shouldAutoAcceptCandidate(result.candidate, config);
result.candidate.status = autoAccept ? 'accepted' : 'candidate';
if (autoAccept) metrics.autoReviewed += 1;
else metrics.pendingReview += 1;
}
results.push(result);
}
@@ -189,8 +223,11 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
enabled: true,
skipped: false,
mode: config.effectiveMode,
autoReviewEnabled: config.autoReviewEnabled,
observed: results.length,
accepted: results.filter((result) => result.accepted).length,
autoReviewed: results.filter((result) => result.accepted && result.candidate?.status === 'accepted').length,
pendingReview: results.filter((result) => result.accepted && result.candidate?.status === 'candidate').length,
rejected: results.filter((result) => !result.accepted).length,
};
} catch (err) {
@@ -205,7 +242,8 @@ export function createPersonalMemoryShadowPipeline({ env = process.env, now = ()
enabled: config.enabled,
requestedMode: config.requestedMode,
effectiveMode: config.effectiveMode,
phase: 'shadow-candidate-v1',
phase: config.autoReviewEnabled ? 'auto-review-v1' : 'shadow-candidate-v1',
autoReviewEnabled: config.autoReviewEnabled,
injectionEnabled: false,
persistence: store?.saveCandidate ? 'mysql' : 'bounded-memory',
pendingCandidates: candidates.length,