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>
218 lines
8.0 KiB
JavaScript
218 lines
8.0 KiB
JavaScript
#!/usr/bin/env node
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { createDbPool, isDatabaseConfigured } from '../db.mjs';
|
|
import { createMemoryV2LifecycleService } from '../memory-v2-lifecycle.mjs';
|
|
import { resolveCandidateAutoReviewScopes, runCandidateAutoReviewBatch } from '../memory-v2-candidate-auto-review.mjs';
|
|
import { syncLegacyUserMemoriesToPgvector } from '../memory-v2-pgvector-backfill.mjs';
|
|
import { evaluateMemoryV2PhaseAReadiness } from '../memory-v2-phase-a-ready.mjs';
|
|
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
|
|
|
|
function usage() {
|
|
return [
|
|
'Usage: node scripts/run-memory-v2-phase-a-closure.mjs [--json] [--skip-auto-review] [--skip-promote] [--skip-pgvector]',
|
|
'',
|
|
'Runs canary closure: auto-review pending candidates → promote accepted → pgvector user sync.',
|
|
].join('\n');
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
json: false,
|
|
skipAutoReview: false,
|
|
skipPromote: false,
|
|
skipPgvector: false,
|
|
help: false,
|
|
};
|
|
for (const arg of argv) {
|
|
if (arg === '--json') options.json = true;
|
|
else if (arg === '--skip-auto-review') options.skipAutoReview = true;
|
|
else if (arg === '--skip-promote') options.skipPromote = true;
|
|
else if (arg === '--skip-pgvector') options.skipPgvector = true;
|
|
else if (arg === '--help' || arg === '-h') options.help = true;
|
|
else throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
async function loadEmbedMemory(env) {
|
|
const modulePath = env.MEMORY_PGVECTOR_EMBEDDING_MODULE;
|
|
if (!modulePath) throw new Error('MEMORY_PGVECTOR_EMBEDDING_MODULE is required for pgvector sync');
|
|
const resolved = path.isAbsolute(modulePath)
|
|
? modulePath
|
|
: path.resolve(process.cwd(), modulePath);
|
|
const mod = await import(pathToFileURL(resolved).href);
|
|
const embedMemory = mod.embedMemory ?? mod.default;
|
|
if (typeof embedMemory !== 'function') {
|
|
throw new Error('embedding module must export embedMemory(memory) or default');
|
|
}
|
|
return embedMemory;
|
|
}
|
|
|
|
async function countCandidatesByStatus(pool) {
|
|
const [rows] = await pool.query(
|
|
`SELECT status, COUNT(*) AS count FROM h5_memory_v2_candidates GROUP BY status`,
|
|
);
|
|
return Object.fromEntries(rows.map((row) => [String(row.status), Number(row.count)]));
|
|
}
|
|
|
|
async function countActiveMemoriesForUsers(pool, userIds) {
|
|
if (!userIds.length) return 0;
|
|
const placeholders = userIds.map(() => '?').join(', ');
|
|
const [rows] = await pool.query(
|
|
`SELECT COUNT(*) AS count FROM h5_user_memory_items
|
|
WHERE status = 'active' AND user_id IN (${placeholders})`,
|
|
userIds,
|
|
);
|
|
return Number(rows[0]?.count ?? 0);
|
|
}
|
|
|
|
async function countPgvectorForUsers(pgPool, userIds) {
|
|
if (!pgPool?.query || !userIds.length) return null;
|
|
const { rows } = await pgPool.query(
|
|
`SELECT COUNT(*)::int AS count FROM memory_embeddings WHERE user_id = ANY($1::text[])`,
|
|
[userIds],
|
|
);
|
|
return Number(rows[0]?.count ?? 0);
|
|
}
|
|
|
|
export async function runMemoryV2PhaseAClosure(env = process.env, options = {}) {
|
|
if (!isDatabaseConfigured()) {
|
|
throw new Error('DATABASE_URL or MYSQL_* must be configured');
|
|
}
|
|
|
|
const readiness = evaluateMemoryV2PhaseAReadiness(env);
|
|
const canaryUserIds = resolveCandidateAutoReviewScopes(env);
|
|
const mysqlPool = createDbPool();
|
|
let pgPool = null;
|
|
|
|
const report = {
|
|
ok: false,
|
|
readiness: readiness.summary,
|
|
canaryUserIds,
|
|
before: {},
|
|
after: {},
|
|
steps: {},
|
|
};
|
|
|
|
try {
|
|
report.before.candidateCounts = await countCandidatesByStatus(mysqlPool);
|
|
report.before.activeMemories = await countActiveMemoriesForUsers(mysqlPool, canaryUserIds);
|
|
if (env.MEMORY_PGVECTOR_DATABASE_URL) {
|
|
const { default: pg } = await import('pg');
|
|
pgPool = new pg.Pool({ connectionString: env.MEMORY_PGVECTOR_DATABASE_URL, max: 1 });
|
|
report.before.pgvectorMemories = await countPgvectorForUsers(pgPool, canaryUserIds);
|
|
}
|
|
|
|
if (!options.skipAutoReview) {
|
|
report.steps.autoReview = await runCandidateAutoReviewBatch(mysqlPool, {
|
|
env,
|
|
limit: 500,
|
|
reviewedBy: 'system:phase-a-closure',
|
|
});
|
|
} else {
|
|
report.steps.autoReview = { skipped: true };
|
|
}
|
|
|
|
const lifecycle = createMemoryV2LifecycleService({ pool: mysqlPool, env });
|
|
const promoteResults = [];
|
|
if (!options.skipPromote) {
|
|
for (const userId of canaryUserIds.length ? canaryUserIds : [null]) {
|
|
const input = userId ? { userId, limit: 200 } : { limit: 200 };
|
|
promoteResults.push({
|
|
userId,
|
|
result: await lifecycle.promote(input),
|
|
});
|
|
}
|
|
report.steps.promote = promoteResults;
|
|
} else {
|
|
report.steps.promote = { skipped: true };
|
|
}
|
|
|
|
if (!options.skipPgvector && env.MEMORY_PGVECTOR_DATABASE_URL) {
|
|
if (!pgPool) {
|
|
const { default: pg } = await import('pg');
|
|
pgPool = new pg.Pool({ connectionString: env.MEMORY_PGVECTOR_DATABASE_URL, max: 1 });
|
|
}
|
|
const embedMemory = await loadEmbedMemory(env);
|
|
const syncResults = [];
|
|
for (const userId of canaryUserIds) {
|
|
syncResults.push(await syncLegacyUserMemoriesToPgvector({
|
|
mysqlPool,
|
|
pgPool,
|
|
embedMemory,
|
|
tableName: env.MEMORY_PGVECTOR_TABLE ?? 'memory_embeddings',
|
|
userId,
|
|
limit: Number(env.MEMORY_PGVECTOR_SYNC_USER_LIMIT ?? 50) || 50,
|
|
}));
|
|
}
|
|
report.steps.pgvectorSync = syncResults;
|
|
report.after.pgvectorMemories = await countPgvectorForUsers(pgPool, canaryUserIds);
|
|
} else {
|
|
report.steps.pgvectorSync = { skipped: true, reason: 'pgvector not configured' };
|
|
}
|
|
|
|
report.after.candidateCounts = await countCandidatesByStatus(mysqlPool);
|
|
report.after.activeMemories = await countActiveMemoriesForUsers(mysqlPool, canaryUserIds);
|
|
|
|
const promotedTotal = Array.isArray(report.steps.promote)
|
|
? report.steps.promote.reduce((sum, item) => sum + Number(item.result?.promoted ?? 0), 0)
|
|
: 0;
|
|
|
|
const pendingAfter = Number(report.after.candidateCounts?.candidate ?? 0);
|
|
const acceptedAfter = Number(report.after.candidateCounts?.accepted ?? 0);
|
|
|
|
report.ok = readiness.ok
|
|
&& pendingAfter === 0
|
|
&& report.after.activeMemories > 0
|
|
&& (acceptedAfter === 0 || promotedTotal > 0 || report.after.activeMemories >= report.before.activeMemories)
|
|
&& (options.skipPgvector || !env.MEMORY_PGVECTOR_DATABASE_URL
|
|
|| Number(report.after.pgvectorMemories ?? 0) >= Math.max(1, report.after.activeMemories - 5));
|
|
|
|
report.summary = {
|
|
pendingCandidates: Number(report.after.candidateCounts?.candidate ?? 0),
|
|
acceptedCandidates: Number(report.after.candidateCounts?.accepted ?? 0),
|
|
promotedThisRun: promotedTotal,
|
|
activeMemoriesCanary: report.after.activeMemories,
|
|
pgvectorMemoriesCanary: report.after.pgvectorMemories ?? null,
|
|
};
|
|
|
|
return report;
|
|
} finally {
|
|
await pgPool?.end?.();
|
|
await mysqlPool.end?.();
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
loadMemindEnvFiles(process.cwd());
|
|
const report = await runMemoryV2PhaseAClosure(process.env, options);
|
|
if (options.json) {
|
|
console.log(JSON.stringify(report, null, 2));
|
|
} else {
|
|
console.log(`memory v2 phase-a closure: ${report.ok ? 'ok' : 'check required'}`);
|
|
console.log(JSON.stringify(report.summary, null, 2));
|
|
if (report.steps.autoReview && !report.steps.autoReview.skipped) {
|
|
console.log(`auto-review: scanned=${report.steps.autoReview.scanned} accept=${report.steps.autoReview.accepted} reject=${report.steps.autoReview.rejected}`);
|
|
}
|
|
if (Array.isArray(report.steps.promote)) {
|
|
for (const item of report.steps.promote) {
|
|
console.log(`promote user=${item.userId ?? 'all'} promoted=${item.result?.promoted ?? 0}`);
|
|
}
|
|
}
|
|
}
|
|
process.exit(report.ok ? 0 : 1);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
void main().catch((err) => {
|
|
console.error(err instanceof Error ? err.message : err);
|
|
process.exit(1);
|
|
});
|
|
}
|