diff --git a/memory-v2-personal-shadow.mjs b/memory-v2-personal-shadow.mjs index c389f2e..a215a79 100644 --- a/memory-v2-personal-shadow.mjs +++ b/memory-v2-personal-shadow.mjs @@ -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, diff --git a/memory-v2-personal-shadow.test.mjs b/memory-v2-personal-shadow.test.mjs index be272be..bcbe5ac 100644 --- a/memory-v2-personal-shadow.test.mjs +++ b/memory-v2-personal-shadow.test.mjs @@ -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', diff --git a/memory-v2-personal-store.mjs b/memory-v2-personal-store.mjs index 402b971..a5c8d7c 100644 --- a/memory-v2-personal-store.mjs +++ b/memory-v2-personal-store.mjs @@ -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 } = {}) { diff --git a/memory-v2-personal-store.test.mjs b/memory-v2-personal-store.test.mjs index 60f1ae1..b4ff3f2 100644 --- a/memory-v2-personal-store.test.mjs +++ b/memory-v2-personal-store.test.mjs @@ -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 });