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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user