#!/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); }); }