Files
memind/scripts/audit-memory-v2-shadow.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

283 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import mysql from 'mysql2/promise';
import {
auditMemoryV2Shadow,
formatMemoryV2ShadowAuditReport,
parseSinceArg,
} from '../memory-v2-shadow-audit.mjs';
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
function usage() {
return [
'Usage: node scripts/audit-memory-v2-shadow.mjs [--since 7d] [--user-id <id>] [--output <path>] [--json]',
'',
'Audits Memory V2 shadow/canary data quality:',
'- candidate status and policy reason distribution',
'- suspicious false-store samples',
'- active memories missing from pgvector',
'- agent_memory_resolved hit rate',
].join('\n');
}
function parseArgs(argv) {
const args = {
since: '7d',
userId: '',
output: '',
json: false,
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--since' && argv[i + 1]) {
args.since = argv[++i];
} else if (arg === '--user-id' && argv[i + 1]) {
args.userId = argv[++i];
} else if (arg === '--output' && argv[i + 1]) {
args.output = argv[++i];
} else if (arg === '--json') {
args.json = true;
} else if (arg === '--help' || arg === '-h') {
console.log(usage());
process.exit(0);
} else {
console.error(`Unknown argument: ${arg}`);
console.error(usage());
process.exit(2);
}
}
return args;
}
function createMysqlPoolFromEnv() {
loadMemindEnvFiles(process.cwd());
const poolOptions = { connectionLimit: 3 };
if (process.env.DATABASE_URL) {
return mysql.createPool({
uri: process.env.DATABASE_URL,
...poolOptions,
});
}
if (!process.env.MYSQL_HOST && !process.env.MYSQL_DATABASE) {
throw new Error('Memory V2 shadow audit requires DATABASE_URL or MYSQL_* configuration');
}
return mysql.createPool({
host: process.env.MYSQL_HOST ?? 'localhost',
port: Number(process.env.MYSQL_PORT ?? 3306),
user: process.env.MYSQL_USER ?? 'boot',
password: process.env.MYSQL_PASSWORD ?? '',
database: process.env.MYSQL_DATABASE ?? 'tkmind',
...poolOptions,
});
}
async function createPgPoolFromEnv() {
const connectionString = String(process.env.MEMORY_PGVECTOR_DATABASE_URL ?? '').trim();
if (!connectionString) return null;
const imported = await import('pg');
const PgPool = imported?.Pool ?? imported?.default?.Pool;
if (typeof PgPool !== 'function') {
throw new Error('pg module does not export Pool');
}
return new PgPool({
connectionString,
max: Math.max(1, Number(process.env.MEMORY_PGVECTOR_POOL_MAX ?? 3) || 3),
});
}
function isSafeIdentifier(value) {
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(String(value ?? ''));
}
async function tableExists(pool, tableName) {
const [rows] = await pool.query(
`SELECT 1
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
LIMIT 1`,
[tableName],
);
return rows.length > 0;
}
async function loadCandidates(pool, { sinceMs, userId }) {
if (!(await tableExists(pool, 'h5_memory_v2_candidates'))) {
return { rows: [], tableMissing: true };
}
const clauses = ['created_at >= ?'];
const params = [sinceMs];
if (userId) {
clauses.push('user_id = ?');
params.push(userId);
}
const [rows] = await pool.query(
`SELECT id, user_id, session_id, memory_type, content, status,
policy_reason, confidence, importance, created_at, updated_at
FROM h5_memory_v2_candidates
WHERE ${clauses.join(' AND ')}
ORDER BY created_at DESC
LIMIT 5000`,
params,
);
return { rows, tableMissing: false };
}
async function loadMemoryItems(pool, { sinceMs, userId }) {
const clauses = ['updated_at >= ?'];
const params = [sinceMs];
if (userId) {
clauses.push('user_id = ?');
params.push(userId);
}
const [rows] = await pool.query(
`SELECT id, user_id, label, memory_text, status, created_at, updated_at
FROM h5_user_memory_items
WHERE ${clauses.join(' AND ')}
ORDER BY updated_at DESC
LIMIT 5000`,
params,
);
const activeClauses = ["status = 'active'"];
const activeParams = [];
if (userId) {
activeClauses.push('user_id = ?');
activeParams.push(userId);
}
const [activeRows] = await pool.query(
`SELECT id, user_id, label, memory_text, status, created_at, updated_at
FROM h5_user_memory_items
WHERE ${activeClauses.join(' AND ')}
ORDER BY updated_at DESC
LIMIT 10000`,
activeParams,
);
return {
updatedRows: rows,
activeRows,
};
}
async function loadAgentMemoryEvents(pool, { sinceMs, userId }) {
const clauses = ["e.event_type = 'agent_memory_resolved'", 'e.created_at >= ?'];
const params = [sinceMs];
if (userId) {
clauses.push('r.user_id = ?');
params.push(userId);
}
const [rows] = await pool.query(
`SELECT e.event_type, e.data_json, e.created_at, r.user_id
FROM h5_agent_run_events e
INNER JOIN h5_agent_runs r ON r.id = e.run_id
WHERE ${clauses.join(' AND ')}
ORDER BY e.created_at DESC
LIMIT 5000`,
params,
);
return rows;
}
async function loadPgvectorMemoryIds(pgPool, { userId, tableName }) {
if (!pgPool) return new Set();
if (!isSafeIdentifier(tableName)) {
throw new Error(`Invalid pgvector table name: ${tableName}`);
}
const clauses = ['source_memory_id IS NOT NULL'];
const params = [];
if (userId) {
clauses.push('user_id = $1');
params.push(userId);
}
try {
const result = await pgPool.query(
`SELECT source_memory_id
FROM "${tableName}"
WHERE ${clauses.join(' AND ')}`,
params,
);
return new Set(
result.rows
.map((row) => String(row.source_memory_id ?? '').trim())
.filter(Boolean),
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to query pgvector table "${tableName}": ${message}`);
}
}
async function writeOutput(outputPath, payload) {
if (!outputPath) return;
const resolved = path.resolve(outputPath);
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.writeFile(resolved, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
const args = parseArgs(process.argv);
const nowMs = Date.now();
const { sinceMs, label: sinceLabel } = parseSinceArg(args.since, nowMs);
const mysqlPool = createMysqlPoolFromEnv();
let pgPool = null;
try {
pgPool = await createPgPoolFromEnv();
const [candidateResult, memoryResult, agentMemoryEvents] = await Promise.all([
loadCandidates(mysqlPool, { sinceMs, userId: args.userId }),
loadMemoryItems(mysqlPool, { sinceMs, userId: args.userId }),
loadAgentMemoryEvents(mysqlPool, { sinceMs, userId: args.userId }),
]);
const pgvectorTable = String(process.env.MEMORY_PGVECTOR_TABLE ?? 'memory_embeddings').trim()
|| 'memory_embeddings';
const pgvectorMemoryIds = await loadPgvectorMemoryIds(pgPool, {
userId: args.userId,
tableName: pgvectorTable,
});
const report = auditMemoryV2Shadow({
candidates: candidateResult.rows,
memoryItems: memoryResult.activeRows,
pgvectorMemoryIds,
pgvectorConfigured: Boolean(pgPool),
agentMemoryEvents,
sinceMs,
nowMs,
});
const payload = {
...report,
meta: {
since: sinceLabel,
userId: args.userId || null,
candidateTableMissing: candidateResult.tableMissing,
pgvectorConfigured: Boolean(pgPool),
pgvectorTable,
},
};
await writeOutput(args.output, payload);
if (args.json) {
console.log(JSON.stringify(payload, null, 2));
} else {
console.log(formatMemoryV2ShadowAuditReport(payload));
if (candidateResult.tableMissing) {
console.log('\nwarning: h5_memory_v2_candidates table is missing');
}
if (!pgPool) {
console.log('\nwarning: MEMORY_PGVECTOR_DATABASE_URL not configured; pgvector lag check skipped');
}
if (args.output) {
console.log(`\nreport written: ${path.resolve(args.output)}`);
}
}
process.exit(report.ok || !pgPool || candidateResult.tableMissing ? 0 : 1);
} finally {
await mysqlPool.end();
await pgPool?.end?.();
}