e90a2da6cb
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>
128 lines
4.8 KiB
JavaScript
128 lines
4.8 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) {
|
|
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 };
|
|
},
|
|
};
|
|
}
|
|
|