6f3e53a56a
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>
187 lines
5.8 KiB
JavaScript
187 lines
5.8 KiB
JavaScript
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,
|
|
};
|
|
}
|