Files
memind/scripts/run-memory-v2-phase-a-closure.mjs
T
john 6f3e53a56a feat(memory-v2): close Phase A with auto-review, product events, and H5 recall UI.
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>
2026-08-01 17:14:06 +08:00

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);
});
}