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