feat(memory-v2): restore personal shadow pipeline and candidate store
Recover WIP modules from stash so admin can persist capability config, observe chat writes in shadow mode, and review persisted candidates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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) {
|
||||
const updatedAt = now();
|
||||
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', ?, ?, ?, ?)`,
|
||||
[
|
||||
candidate.id,
|
||||
candidate.userId,
|
||||
candidate.sessionId,
|
||||
candidate.memoryType,
|
||||
candidate.content,
|
||||
candidate.importance,
|
||||
candidate.confidence,
|
||||
candidate.policyReason,
|
||||
JSON.stringify(candidate.evidence ?? {}),
|
||||
candidate.createdAt,
|
||||
updatedAt,
|
||||
],
|
||||
);
|
||||
return { inserted: Number(result?.affectedRows ?? 0) > 0 };
|
||||
},
|
||||
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user