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>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
#!/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?.();
|
||||
}
|
||||
Reference in New Issue
Block a user