Files
memind/memory-v2-personal-store.mjs
john b33c943b69 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>
2026-07-13 14:16:57 +08:00

134 lines
5.2 KiB
JavaScript

const TABLE = 'h5_memory_v2_candidates';
const ALLOWED_STATUSES = new Set(['candidate', 'accepted', 'rejected', 'forgotten']);
export function buildPersonalMemoryCandidateSchemaSql({ table = TABLE } = {}) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error('Invalid candidate table name');
return `CREATE TABLE IF NOT EXISTS \`${table}\` (
id VARCHAR(64) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
session_id VARCHAR(191) NULL,
memory_type VARCHAR(32) NOT NULL,
content TEXT NOT NULL,
importance DECIMAL(5,4) NOT NULL,
confidence DECIMAL(5,4) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'candidate',
policy_reason VARCHAR(64) NOT NULL,
evidence_json JSON NOT NULL,
reviewed_by CHAR(36) NULL,
reviewed_at BIGINT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uniq_user_type_content (user_id, memory_type, id),
KEY idx_candidate_status_created (status, created_at),
KEY idx_candidate_user_status (user_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`;
}
export async function ensurePersonalMemoryCandidateSchema(pool, options = {}) {
if (!pool?.query) throw new Error('Candidate schema requires a MySQL pool');
await pool.query(buildPersonalMemoryCandidateSchemaSql(options));
}
function parseJson(value, fallback = null) {
if (value == null) return fallback;
if (typeof value === 'object') return value;
try { return JSON.parse(value); } catch { return fallback; }
}
function normalizeRow(row) {
return {
id: String(row.id),
userId: String(row.user_id),
sessionId: row.session_id == null ? null : String(row.session_id),
memoryType: String(row.memory_type),
content: String(row.content),
importance: Number(row.importance),
confidence: Number(row.confidence),
status: String(row.status),
policyReason: String(row.policy_reason),
evidence: parseJson(row.evidence_json, {}),
reviewedBy: row.reviewed_by == null ? null : String(row.reviewed_by),
reviewedAt: row.reviewed_at == null ? null : Number(row.reviewed_at),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
export function createPersonalMemoryCandidateStore(pool, { table = TABLE, now = () => Date.now() } = {}) {
if (!pool?.query) return null;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error('Invalid candidate table name');
const quoted = `\`${table}\``;
return {
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, reviewed_by, reviewed_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
candidate.id,
candidate.userId,
candidate.sessionId,
candidate.memoryType,
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, status };
},
async listCandidates({ status = 'candidate', userId = null, limit = 50, offset = 0 } = {}) {
const normalizedStatus = String(status || 'candidate');
if (!ALLOWED_STATUSES.has(normalizedStatus)) throw new Error('Invalid candidate status');
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50));
const safeOffset = Math.max(0, Number(offset) || 0);
const where = ['status = ?'];
const params = [normalizedStatus];
if (userId) {
where.push('user_id = ?');
params.push(String(userId));
}
params.push(safeLimit, safeOffset);
const [rows] = await pool.query(
`SELECT * FROM ${quoted} WHERE ${where.join(' AND ')}
ORDER BY created_at DESC LIMIT ? OFFSET ?`,
params,
);
return rows.map(normalizeRow);
},
async countByStatus() {
const [rows] = await pool.query(
`SELECT status, COUNT(*) AS count FROM ${quoted} GROUP BY status`,
);
return Object.fromEntries(rows.map((row) => [String(row.status), Number(row.count)]));
},
async reviewCandidate(id, status, { reviewedBy = null } = {}) {
if (!['accepted', 'rejected'].includes(status)) throw new Error('Invalid review status');
const reviewedAt = now();
const [result] = await pool.query(
`UPDATE ${quoted}
SET status = ?, reviewed_by = ?, reviewed_at = ?, updated_at = ?
WHERE id = ? AND status = 'candidate'`,
[status, reviewedBy, reviewedAt, reviewedAt, String(id)],
);
return { updated: Number(result?.affectedRows ?? 0) > 0, status, reviewedAt };
},
};
}