716ef407fe
Wire Portal and TKMind proxy to loopback Goose v1.49 via canary env blocks, with verification scripts, Phase 2/3 evidence baselines, and rollback runbooks so local upgrade stays isolated from stable 1.41 and production. Co-authored-by: Cursor <cursoragent@cursor.com>
306 lines
9.9 KiB
JavaScript
306 lines
9.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Export local Memory baseline manifest for Goose v1.49 cutover reconciliation.
|
|
* Loopback / local MySQL only; refuses production DB hosts.
|
|
*/
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import mysql from 'mysql2/promise';
|
|
|
|
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
|
|
|
|
const BLOCKED_DB_HOSTS = new Set(['58.38.22.103', '120.26.184.105', 'rds.aliyuncs.com']);
|
|
|
|
function usage() {
|
|
return [
|
|
'Usage: node scripts/export-goose-v149-memory-manifest.mjs [--output <path>] [--user-id <id>] [--json]',
|
|
'',
|
|
'Exports per-user memory ID/hash/status/source session/evidence, V2 candidates,',
|
|
'vector namespace metadata, and schema/watermark fields for Phase 0 baseline.',
|
|
].join('\n');
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = { output: '', userId: '', json: false };
|
|
for (let i = 2; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === '--output' && argv[i + 1]) args.output = argv[++i];
|
|
else if (arg === '--user-id' && argv[i + 1]) args.userId = 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 assertLocalDatabaseUrl(env = process.env) {
|
|
const raw = String(env.DATABASE_URL ?? '').trim();
|
|
if (raw) {
|
|
let hostname = '';
|
|
try {
|
|
hostname = new URL(raw.replace(/^mysql:/, 'http:')).hostname.toLowerCase();
|
|
} catch {
|
|
throw new Error('DATABASE_URL is not a valid URL');
|
|
}
|
|
for (const blocked of BLOCKED_DB_HOSTS) {
|
|
if (hostname.includes(blocked)) {
|
|
throw new Error(`Refusing production DATABASE_URL host: ${hostname}`);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
const host = String(env.MYSQL_HOST ?? '127.0.0.1').trim().toLowerCase();
|
|
for (const blocked of BLOCKED_DB_HOSTS) {
|
|
if (host.includes(blocked)) {
|
|
throw new Error(`Refusing production MYSQL_HOST: ${host}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createMysqlPoolFromEnv() {
|
|
loadMemindEnvFiles(process.cwd());
|
|
assertLocalDatabaseUrl(process.env);
|
|
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 manifest export requires DATABASE_URL or MYSQL_* configuration');
|
|
}
|
|
return mysql.createPool({
|
|
host: process.env.MYSQL_HOST ?? '127.0.0.1',
|
|
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 tableExists(pool, tableName) {
|
|
const [rows] = await pool.query(
|
|
`SELECT COUNT(*) AS count
|
|
FROM information_schema.tables
|
|
WHERE table_schema = DATABASE() AND table_name = ?`,
|
|
[tableName],
|
|
);
|
|
return Number(rows[0]?.count ?? 0) > 0;
|
|
}
|
|
|
|
async function fetchMemoryItems(pool, userId) {
|
|
const clauses = ["status IN ('active', 'archived')"];
|
|
const params = [];
|
|
if (userId) {
|
|
clauses.push('user_id = ?');
|
|
params.push(userId);
|
|
}
|
|
const [rows] = await pool.query(
|
|
`SELECT id, user_id, label, memory_hash, memory_text, evidence_message_id,
|
|
source_session_id, confidence, status, created_at, updated_at
|
|
FROM h5_user_memory_items
|
|
WHERE ${clauses.join(' AND ')}
|
|
ORDER BY user_id ASC, updated_at DESC`,
|
|
params,
|
|
);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
label: row.label,
|
|
memoryHash: row.memory_hash,
|
|
memoryTextPreview: String(row.memory_text ?? '').slice(0, 120),
|
|
evidenceMessageId: row.evidence_message_id ?? null,
|
|
sourceSessionId: row.source_session_id ?? null,
|
|
confidence: Number(row.confidence ?? 0),
|
|
status: row.status,
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at),
|
|
}));
|
|
}
|
|
|
|
async function fetchCandidates(pool, userId) {
|
|
if (!(await tableExists(pool, 'h5_memory_v2_candidates'))) return [];
|
|
const clauses = ['1=1'];
|
|
const params = [];
|
|
if (userId) {
|
|
clauses.push('user_id = ?');
|
|
params.push(userId);
|
|
}
|
|
const [rows] = await pool.query(
|
|
`SELECT id, user_id, session_id, memory_type, content, importance, confidence,
|
|
status, policy_reason, evidence_json, created_at, updated_at
|
|
FROM h5_memory_v2_candidates
|
|
WHERE ${clauses.join(' AND ')}
|
|
ORDER BY user_id ASC, updated_at DESC`,
|
|
params,
|
|
);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
sessionId: row.session_id ?? null,
|
|
memoryType: row.memory_type,
|
|
contentPreview: String(row.content ?? '').slice(0, 120),
|
|
importance: Number(row.importance ?? 0),
|
|
confidence: Number(row.confidence ?? 0),
|
|
status: row.status,
|
|
policyReason: row.policy_reason ?? null,
|
|
evidence: (() => {
|
|
try {
|
|
return typeof row.evidence_json === 'object'
|
|
? row.evidence_json
|
|
: JSON.parse(String(row.evidence_json ?? '{}'));
|
|
} catch {
|
|
return {};
|
|
}
|
|
})(),
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at),
|
|
}));
|
|
}
|
|
|
|
async function fetchVectorSummary(env) {
|
|
const connectionString = String(env.MEMORY_PGVECTOR_DATABASE_URL ?? '').trim();
|
|
if (!connectionString) {
|
|
return {
|
|
enabled: false,
|
|
table: env.MEMORY_PGVECTOR_TABLE ?? 'memory_embeddings',
|
|
rowCount: 0,
|
|
embeddingModule: env.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? null,
|
|
};
|
|
}
|
|
for (const blocked of BLOCKED_DB_HOSTS) {
|
|
if (connectionString.includes(blocked)) {
|
|
throw new Error('Refusing production MEMORY_PGVECTOR_DATABASE_URL');
|
|
}
|
|
}
|
|
const imported = await import('pg');
|
|
const PgPool = imported?.Pool ?? imported?.default?.Pool;
|
|
if (typeof PgPool !== 'function') {
|
|
return { enabled: false, error: 'pg module unavailable' };
|
|
}
|
|
const table = String(env.MEMORY_PGVECTOR_TABLE ?? 'memory_embeddings').replace(/"/g, '');
|
|
const pool = new PgPool({ connectionString, max: 2 });
|
|
try {
|
|
const result = await pool.query(`SELECT COUNT(*)::int AS count FROM "${table}"`);
|
|
return {
|
|
enabled: true,
|
|
connectionHost: new URL(connectionString).hostname,
|
|
table,
|
|
rowCount: Number(result.rows[0]?.count ?? 0),
|
|
embeddingModule: env.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? null,
|
|
dimensions: Number(env.MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS ?? 0) || null,
|
|
};
|
|
} finally {
|
|
await pool.end().catch(() => {});
|
|
}
|
|
}
|
|
|
|
function buildManifest({ memories, candidates, vectorSummary, env, userId }) {
|
|
const exportedAt = new Date().toISOString();
|
|
const watermarkMs = Date.now();
|
|
const digest = crypto
|
|
.createHash('sha256')
|
|
.update(JSON.stringify({ memories, candidates, vectorSummary, watermarkMs }))
|
|
.digest('hex');
|
|
|
|
const byUser = new Map();
|
|
for (const item of memories) {
|
|
if (!byUser.has(item.userId)) {
|
|
byUser.set(item.userId, { memoryCount: 0, memoryIds: [], memoryHashes: [] });
|
|
}
|
|
const bucket = byUser.get(item.userId);
|
|
bucket.memoryCount += 1;
|
|
bucket.memoryIds.push(item.id);
|
|
bucket.memoryHashes.push(item.memoryHash);
|
|
}
|
|
|
|
return {
|
|
schemaVersion: 'goose-v149-memory-manifest-v1',
|
|
exportedAt,
|
|
cutoverWatermarkMs: watermarkMs,
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
textNormalization: 'utf8mb4_unicode_ci',
|
|
scope: userId ? { userId } : { allUsers: true },
|
|
memoryPolicy: {
|
|
backend: env.MEMORY_BACKEND ?? 'legacy',
|
|
vectorEnabled: env.MEMORY_VECTOR_ENABLED ?? '0',
|
|
agentInjectionMode: env.MEMORY_AGENT_INJECTION_MODE ?? 'off',
|
|
lifecycleEnabled: env.MEMORY_LIFECYCLE_ENABLED ?? '0',
|
|
candidateEnabled: env.MEMORY_CANDIDATE_ENABLED ?? '0',
|
|
},
|
|
gooseSessionDb: {
|
|
urlRedacted: String(env.GOOSE_SESSION_DB_URL ?? env.GOOSE_V149_SESSION_DB ?? 'goose_sessions_v149_dev')
|
|
.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'),
|
|
schemaVersion: 16,
|
|
},
|
|
harnessMemoryKeys: [],
|
|
vector: vectorSummary,
|
|
summary: {
|
|
userCount: byUser.size,
|
|
memoryItemCount: memories.length,
|
|
candidateCount: candidates.length,
|
|
statusBreakdown: memories.reduce((acc, item) => {
|
|
acc[item.status] = (acc[item.status] ?? 0) + 1;
|
|
return acc;
|
|
}, {}),
|
|
digest,
|
|
},
|
|
users: [...byUser.entries()].map(([id, stats]) => ({
|
|
userId: id,
|
|
...stats,
|
|
})),
|
|
memories,
|
|
candidates,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv);
|
|
const pool = createMysqlPoolFromEnv();
|
|
try {
|
|
const memories = await fetchMemoryItems(pool, args.userId);
|
|
const candidates = await fetchCandidates(pool, args.userId);
|
|
const vectorSummary = await fetchVectorSummary(process.env);
|
|
const manifest = buildManifest({
|
|
memories,
|
|
candidates,
|
|
vectorSummary,
|
|
env: process.env,
|
|
userId: args.userId,
|
|
});
|
|
|
|
const defaultOutput = path.join(
|
|
process.cwd(),
|
|
'docs',
|
|
'baselines',
|
|
`goose-v149-memory-manifest-${manifest.exportedAt.replace(/[:.]/g, '-')}.json`,
|
|
);
|
|
const outputPath = args.output ? path.resolve(args.output) : defaultOutput;
|
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
await fs.writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
|
|
if (args.json) {
|
|
console.log(JSON.stringify({ ok: true, outputPath, summary: manifest.summary }, null, 2));
|
|
} else {
|
|
console.log(`GOOSE_V149_MEMORY_MANIFEST_OK: ${outputPath}`);
|
|
console.log(
|
|
` users=${manifest.summary.userCount} memories=${manifest.summary.memoryItemCount} candidates=${manifest.summary.candidateCount}`,
|
|
);
|
|
console.log(` digest=${manifest.summary.digest}`);
|
|
}
|
|
} finally {
|
|
await pool.end().catch(() => {});
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`GOOSE_V149_MEMORY_MANIFEST_FAIL: ${error.message}`);
|
|
process.exit(1);
|
|
});
|