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>
283 lines
8.0 KiB
JavaScript
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?.();
|
|
}
|