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,
|
||||
|
||||
@@ -1,16 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPersonalMemoryShadowPipeline, resolvePersonalShadowConfig } from './memory-v2-personal-shadow.mjs';
|
||||
import {
|
||||
createPersonalMemoryShadowPipeline,
|
||||
resolvePersonalShadowConfig,
|
||||
shouldAutoAcceptCandidate,
|
||||
} from './memory-v2-personal-shadow.mjs';
|
||||
import { createMemoryV2 } from './memory-v2.mjs';
|
||||
|
||||
test('personal memory shadow config never activates response injection', () => {
|
||||
test('personal memory shadow config maps active mode to auto review', () => {
|
||||
const config = resolvePersonalShadowConfig({
|
||||
MEMORY_CANDIDATE_ENABLED: '1',
|
||||
MEMORY_CANDIDATE_MODE: 'active',
|
||||
});
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.requestedMode, 'active');
|
||||
assert.equal(config.effectiveMode, 'active');
|
||||
assert.equal(config.autoReviewEnabled, true);
|
||||
});
|
||||
|
||||
test('personal memory shadow config keeps shadow mode for manual review', () => {
|
||||
const config = resolvePersonalShadowConfig({
|
||||
MEMORY_CANDIDATE_ENABLED: '1',
|
||||
MEMORY_CANDIDATE_MODE: 'shadow',
|
||||
});
|
||||
assert.equal(config.effectiveMode, 'shadow');
|
||||
assert.equal(config.autoReviewEnabled, false);
|
||||
});
|
||||
|
||||
test('shouldAutoAcceptCandidate auto accepts explicit requests in canary mode', () => {
|
||||
const config = resolvePersonalShadowConfig({
|
||||
MEMORY_CANDIDATE_ENABLED: '1',
|
||||
MEMORY_CANDIDATE_MODE: 'canary',
|
||||
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
|
||||
});
|
||||
assert.equal(shouldAutoAcceptCandidate({
|
||||
policyReason: 'explicit_memory_request',
|
||||
confidence: 0.98,
|
||||
}, config), true);
|
||||
assert.equal(shouldAutoAcceptCandidate({
|
||||
policyReason: 'stable_fact_signal',
|
||||
confidence: 0.82,
|
||||
}, config), false);
|
||||
assert.equal(shouldAutoAcceptCandidate({
|
||||
policyReason: 'decision_signal',
|
||||
confidence: 0.9,
|
||||
}, config), true);
|
||||
});
|
||||
|
||||
test('shadow pipeline auto accepts durable candidates in active mode', async () => {
|
||||
const saved = [];
|
||||
const pipeline = createPersonalMemoryShadowPipeline({
|
||||
env: {
|
||||
MEMORY_CANDIDATE_ENABLED: '1',
|
||||
MEMORY_CANDIDATE_MODE: 'active',
|
||||
MEMORY_POLICY_ENABLED: '1',
|
||||
},
|
||||
store: {
|
||||
async saveCandidate(candidate, options = {}) {
|
||||
saved.push({ candidate, options });
|
||||
return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' };
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await pipeline.observeWrite({
|
||||
userId: 'u1',
|
||||
sessionId: 's1',
|
||||
messages: [{ role: 'user', text: '我决定所有生产发布必须从完整 main 分支打包。' }],
|
||||
});
|
||||
assert.equal(result.autoReviewed, 1);
|
||||
assert.equal(result.pendingReview, 0);
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].options.autoAccept, true);
|
||||
assert.equal(pipeline.getStatus().autoReviewEnabled, true);
|
||||
assert.equal(pipeline.getStatus().phase, 'auto-review-v1');
|
||||
});
|
||||
|
||||
test('shadow pipeline keeps low-confidence canary candidates pending review', async () => {
|
||||
const saved = [];
|
||||
const pipeline = createPersonalMemoryShadowPipeline({
|
||||
env: {
|
||||
MEMORY_CANDIDATE_ENABLED: '1',
|
||||
MEMORY_CANDIDATE_MODE: 'canary',
|
||||
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
|
||||
},
|
||||
store: {
|
||||
async saveCandidate(candidate, options = {}) {
|
||||
saved.push({ candidate, options });
|
||||
return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' };
|
||||
},
|
||||
},
|
||||
});
|
||||
await pipeline.observeWrite({
|
||||
userId: 'u1',
|
||||
sessionId: 's1',
|
||||
messages: [{ role: 'user', text: '项目使用 pgvector 作为向量存储。' }],
|
||||
});
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].options.autoAccept, false);
|
||||
assert.equal(pipeline.getStatus().health.pendingReview, 1);
|
||||
});
|
||||
|
||||
test('shadow pipeline extracts durable candidates with evidence and deduplicates', async () => {
|
||||
@@ -24,7 +111,10 @@ test('shadow pipeline extracts durable candidates with evidence and deduplicates
|
||||
MEMORY_CANDIDATE_MIN_IMPORTANCE: '0.7',
|
||||
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
|
||||
},
|
||||
now: () => clock += 1,
|
||||
now: () => {
|
||||
clock += 1;
|
||||
return clock;
|
||||
},
|
||||
});
|
||||
const input = {
|
||||
userId: 'u1',
|
||||
|
||||
@@ -60,13 +60,16 @@ export function createPersonalMemoryCandidateStore(pool, { table = TABLE, now =
|
||||
const quoted = `\`${table}\``;
|
||||
|
||||
return {
|
||||
async saveCandidate(candidate) {
|
||||
async saveCandidate(candidate, { autoAccept = false, reviewedBy = 'system:auto-review' } = {}) {
|
||||
const updatedAt = now();
|
||||
const status = autoAccept ? 'accepted' : 'candidate';
|
||||
const reviewedAt = autoAccept ? updatedAt : null;
|
||||
const reviewer = autoAccept ? String(reviewedBy || 'system:auto-review') : null;
|
||||
const [result] = await pool.query(
|
||||
`INSERT IGNORE INTO ${quoted}
|
||||
(id, user_id, session_id, memory_type, content, importance, confidence, status,
|
||||
policy_reason, evidence_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'candidate', ?, ?, ?, ?)`,
|
||||
policy_reason, evidence_json, reviewed_by, reviewed_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
candidate.id,
|
||||
candidate.userId,
|
||||
@@ -75,13 +78,16 @@ export function createPersonalMemoryCandidateStore(pool, { table = TABLE, now =
|
||||
candidate.content,
|
||||
candidate.importance,
|
||||
candidate.confidence,
|
||||
status,
|
||||
candidate.policyReason,
|
||||
JSON.stringify(candidate.evidence ?? {}),
|
||||
reviewer,
|
||||
reviewedAt,
|
||||
candidate.createdAt,
|
||||
updatedAt,
|
||||
],
|
||||
);
|
||||
return { inserted: Number(result?.affectedRows ?? 0) > 0 };
|
||||
return { inserted: Number(result?.affectedRows ?? 0) > 0, status };
|
||||
},
|
||||
|
||||
async listCandidates({ status = 'candidate', userId = null, limit = 50, offset = 0 } = {}) {
|
||||
|
||||
@@ -37,7 +37,7 @@ test('candidate store saves, lists, counts, and reviews with parameterized SQL',
|
||||
id: 'pmc_1', userId: 'u1', sessionId: 's1', memoryType: 'episodic',
|
||||
content: '决定使用 main 发布', importance: 0.9, confidence: 0.9,
|
||||
policyReason: 'decision_signal', evidence: { sourceId: 's1:0' }, createdAt: 1,
|
||||
}), { inserted: true });
|
||||
}, { autoAccept: true }), { inserted: true, status: 'accepted' });
|
||||
const rows = await store.listCandidates({ limit: 20 });
|
||||
assert.equal(rows[0].evidence.sourceId, 's1:0');
|
||||
assert.deepEqual(await store.countByStatus(), { candidate: 1 });
|
||||
|
||||
Reference in New Issue
Block a user