Files
memind/scripts/export-goose-v149-memory-manifest.mjs
T
john 716ef407fe feat(goose): add local v1.49 canary routing, smoke gates, and migration docs
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>
2026-09-08 21:10:20 +08:00

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);
});