#!/usr/bin/env node /** * Compare two Goose v1.49 memory manifests and report drift. */ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); function usage() { return [ 'Usage: node scripts/compare-goose-v149-memory-manifest.mjs [--baseline ] [--current ] [--json]', '', 'Defaults:', ' baseline docs/baselines/goose-v149-memory-latest.json', ' current freshly exported manifest (via child process) unless --current is set', ].join('\n'); } function parseArgs(argv) { const args = { baseline: path.join(root, 'docs', 'baselines', 'goose-v149-memory-latest.json'), current: '', json: false, exportCurrent: true, }; for (let i = 2; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--baseline' && argv[i + 1]) args.baseline = path.resolve(argv[++i]); else if (arg === '--current' && argv[i + 1]) { args.current = path.resolve(argv[++i]); args.exportCurrent = false; } 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; } async function readManifest(filePath) { const raw = await fs.readFile(filePath, 'utf8'); return JSON.parse(raw); } function indexMemories(manifest) { const byId = new Map(); for (const item of manifest.memories ?? []) { byId.set(item.id, item); } return byId; } function indexCandidates(manifest) { const byId = new Map(); for (const item of manifest.candidates ?? []) { byId.set(item.id, item); } return byId; } function compareManifests(baseline, current) { const drift = { baselineDigest: baseline.summary?.digest ?? null, currentDigest: current.summary?.digest ?? null, summary: { userCountDelta: (current.summary?.userCount ?? 0) - (baseline.summary?.userCount ?? 0), memoryCountDelta: (current.summary?.memoryItemCount ?? 0) - (baseline.summary?.memoryItemCount ?? 0), candidateCountDelta: (current.summary?.candidateCount ?? 0) - (baseline.summary?.candidateCount ?? 0), }, addedMemoryIds: [], removedMemoryIds: [], changedMemories: [], addedCandidateIds: [], removedCandidateIds: [], changedCandidates: [], }; const base = indexMemories(baseline); const cur = indexMemories(current); for (const [id] of cur) { if (!base.has(id)) drift.addedMemoryIds.push(id); } for (const [id] of base) { if (!cur.has(id)) drift.removedMemoryIds.push(id); } for (const [id, baseItem] of base) { const curItem = cur.get(id); if (!curItem) continue; const fields = [ 'userId', 'memoryHash', 'status', 'sourceSessionId', 'evidenceMessageId', ]; const changes = {}; for (const field of fields) { if (String(baseItem[field] ?? '') !== String(curItem[field] ?? '')) { changes[field] = { baseline: baseItem[field] ?? null, current: curItem[field] ?? null }; } } if (Object.keys(changes).length) { drift.changedMemories.push({ id, userId: baseItem.userId, changes }); } } const baseCandidates = indexCandidates(baseline); const curCandidates = indexCandidates(current); for (const [id] of curCandidates) { if (!baseCandidates.has(id)) drift.addedCandidateIds.push(id); } for (const [id] of baseCandidates) { if (!curCandidates.has(id)) drift.removedCandidateIds.push(id); } for (const [id, baseItem] of baseCandidates) { const curItem = curCandidates.get(id); if (!curItem) continue; const fields = ['userId', 'status', 'sourceSessionId']; const changes = {}; for (const field of fields) { if (String(baseItem[field] ?? '') !== String(curItem[field] ?? '')) { changes[field] = { baseline: baseItem[field] ?? null, current: curItem[field] ?? null }; } } if (Object.keys(changes).length) { drift.changedCandidates.push({ id, userId: baseItem.userId, changes }); } } drift.ok = drift.addedMemoryIds.length === 0 && drift.removedMemoryIds.length === 0 && drift.changedMemories.length === 0 && drift.addedCandidateIds.length === 0 && drift.removedCandidateIds.length === 0 && drift.changedCandidates.length === 0 && drift.summary.candidateCountDelta === 0; return drift; } async function exportCurrentManifest() { const { spawnSync } = await import('node:child_process'); const tmpPath = path.join(root, 'docs', 'baselines', '.goose-v149-memory-current.json'); const result = spawnSync( process.execPath, ['scripts/export-goose-v149-memory-manifest.mjs', '--output', tmpPath, '--json'], { cwd: root, encoding: 'utf8', env: process.env }, ); if (result.status !== 0) { throw new Error(result.stderr || result.stdout || 'export manifest failed'); } return readManifest(tmpPath); } async function main() { const args = parseArgs(process.argv); const baseline = await readManifest(args.baseline); const current = args.exportCurrent ? await exportCurrentManifest() : await readManifest(args.current); const report = compareManifests(baseline, current); if (args.json) { console.log(JSON.stringify(report, null, 2)); } else if (report.ok) { console.log('GOOSE_V149_MEMORY_DRIFT_OK: no user/memory/candidate drift'); console.log(` baselineDigest=${report.baselineDigest}`); } else { console.error('GOOSE_V149_MEMORY_DRIFT_FAIL:'); console.error( ` memory added=${report.addedMemoryIds.length} removed=${report.removedMemoryIds.length} ` + `changed=${report.changedMemories.length}`, ); console.error( ` candidate added=${report.addedCandidateIds.length} removed=${report.removedCandidateIds.length} ` + `changed=${report.changedCandidates.length} delta=${report.summary.candidateCountDelta}`, ); if (report.addedMemoryIds.length) { console.error(` added sample: ${report.addedMemoryIds.slice(0, 5).join(', ')}`); } if (report.removedMemoryIds.length) { console.error(` removed sample: ${report.removedMemoryIds.slice(0, 5).join(', ')}`); } if (report.changedMemories.length) { console.error(` changed sample: ${JSON.stringify(report.changedMemories.slice(0, 3), null, 2)}`); } process.exit(1); } } main().catch((error) => { console.error(`GOOSE_V149_MEMORY_DRIFT_FAIL: ${error.message}`); process.exit(1); });