Merge Memory V2 runtime facade

This commit is contained in:
john
2026-07-03 09:46:03 +08:00
parent 7005c2a834
commit 20116b161f
69 changed files with 11869 additions and 27 deletions
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env node
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { backfillLegacyMemoriesToPgvector } from '../memory-v2-pgvector-backfill.mjs';
const DEFAULT_MYSQL_URL_ENV = 'MEMORY_BACKFILL_MYSQL_URL';
const DEFAULT_PG_URL_ENV = 'MEMORY_PGVECTOR_DATABASE_URL';
function usage() {
return `
Usage:
node scripts/backfill-memory-v2-pgvector.mjs [options]
Default mode is dry-run: scan MySQL source rows, but do not embed or write PostgreSQL.
Options:
--apply Write embeddings into pgvector.
--limit <number> Batch size. Default: 100.
--cursor-updated-at <ms> Checkpoint updated_at value. Default: 0.
--cursor-id <id> Checkpoint id value. Default: empty.
--table <name> pgvector table. Default: memory_embeddings.
--mysql-url-env <name> Env var containing MySQL URL. Default: ${DEFAULT_MYSQL_URL_ENV}.
--pg-url-env <name> Env var containing PostgreSQL URL. Default: ${DEFAULT_PG_URL_ENV}.
--embedding-module <path> Required with --apply. Must export embedMemory(memory) or default.
-h, --help Show this help.
`.trim();
}
export function parseMemoryPgvectorBackfillArgs(argv = []) {
const options = {
apply: false,
limit: 100,
cursor: { updatedAt: 0, id: '' },
tableName: 'memory_embeddings',
mysqlUrlEnv: DEFAULT_MYSQL_URL_ENV,
pgUrlEnv: DEFAULT_PG_URL_ENV,
embeddingModule: null,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
switch (arg) {
case '--apply':
options.apply = true;
break;
case '--limit':
i += 1;
options.limit = Number(argv[i]);
break;
case '--cursor-updated-at':
i += 1;
options.cursor.updatedAt = Number(argv[i]);
break;
case '--cursor-id':
i += 1;
options.cursor.id = String(argv[i] ?? '');
break;
case '--table':
i += 1;
options.tableName = argv[i];
break;
case '--mysql-url-env':
i += 1;
options.mysqlUrlEnv = argv[i];
break;
case '--pg-url-env':
i += 1;
options.pgUrlEnv = argv[i];
break;
case '--embedding-module':
i += 1;
options.embeddingModule = argv[i];
break;
case '-h':
case '--help':
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!Number.isFinite(options.limit)) throw new Error('--limit requires a number');
if (!Number.isFinite(options.cursor.updatedAt)) {
throw new Error('--cursor-updated-at requires a number');
}
if (!options.tableName) throw new Error('--table requires a value');
if (!options.mysqlUrlEnv) throw new Error('--mysql-url-env requires a value');
if (!options.pgUrlEnv) throw new Error('--pg-url-env requires a value');
return options;
}
async function defaultCreateMysqlPool(connectionString) {
const mysql = await import('mysql2/promise');
return mysql.createPool(connectionString);
}
async function defaultCreatePgPool(connectionString) {
const { default: pg } = await import('pg');
return new pg.Pool({ connectionString, max: 1 });
}
async function defaultLoadEmbedMemory(modulePath) {
if (!modulePath) return null;
const resolved = path.isAbsolute(modulePath)
? modulePath
: path.resolve(process.cwd(), modulePath);
const mod = await import(pathToFileURL(resolved).href);
const embedMemory = mod.embedMemory ?? mod.default;
if (typeof embedMemory !== 'function') {
throw new Error('--embedding-module must export embedMemory(memory) or default function');
}
return embedMemory;
}
export async function runMemoryPgvectorBackfillCli(
argv = process.argv.slice(2),
env = process.env,
deps = {},
) {
const options = parseMemoryPgvectorBackfillArgs(argv);
if (options.help) {
console.log(usage());
return { ok: true, mode: 'help' };
}
const mysqlUrl = env[options.mysqlUrlEnv];
if (!mysqlUrl) {
throw new Error(`Backfill requires ${options.mysqlUrlEnv} to be set`);
}
if (options.apply && !env[options.pgUrlEnv]) {
throw new Error(`--apply requires ${options.pgUrlEnv} to be set`);
}
if (options.apply && !options.embeddingModule) {
throw new Error('--apply requires --embedding-module');
}
const createMysqlPool = deps.createMysqlPool ?? defaultCreateMysqlPool;
const createPgPool = deps.createPgPool ?? defaultCreatePgPool;
const loadEmbedMemory = deps.loadEmbedMemory ?? defaultLoadEmbedMemory;
const mysqlPool = await createMysqlPool(mysqlUrl);
let pgPool = null;
try {
let embedMemory = null;
if (options.apply) {
pgPool = await createPgPool(env[options.pgUrlEnv]);
embedMemory = await loadEmbedMemory(options.embeddingModule);
}
const result = await backfillLegacyMemoriesToPgvector({
mysqlPool,
pgPool,
embedMemory,
tableName: options.tableName,
cursor: options.cursor,
limit: options.limit,
dryRun: !options.apply,
});
console.log(JSON.stringify(result, null, 2));
return result;
} finally {
await mysqlPool?.end?.();
await pgPool?.end?.();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryPgvectorBackfillCli().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseMemoryPgvectorBackfillArgs,
runMemoryPgvectorBackfillCli,
} from './backfill-memory-v2-pgvector.mjs';
function createMysqlPool(rows, closed) {
return {
async query() {
return [rows];
},
async end() {
closed.mysql = true;
},
};
}
function createPgPool(calls, closed) {
return {
async query(sql, params) {
calls.push({ sql, params });
return { rows: [] };
},
async end() {
closed.pg = true;
},
};
}
const rows = [
{
id: 'mem-1',
user_id: 'user-1',
label: 'fact',
memory_text: '用户关注 Memory V2',
status: 'active',
created_at: 1000,
updated_at: 2000,
},
];
test('parseMemoryPgvectorBackfillArgs defaults to safe dry-run settings', () => {
assert.deepEqual(parseMemoryPgvectorBackfillArgs([]), {
apply: false,
limit: 100,
cursor: { updatedAt: 0, id: '' },
tableName: 'memory_embeddings',
mysqlUrlEnv: 'MEMORY_BACKFILL_MYSQL_URL',
pgUrlEnv: 'MEMORY_PGVECTOR_DATABASE_URL',
embeddingModule: null,
help: false,
});
});
test('parseMemoryPgvectorBackfillArgs maps checkpoint and apply options', () => {
assert.deepEqual(
parseMemoryPgvectorBackfillArgs([
'--apply',
'--limit',
'25',
'--cursor-updated-at',
'12345',
'--cursor-id',
'mem-9',
'--table',
'memory_embeddings_local',
'--mysql-url-env',
'LOCAL_MYSQL_URL',
'--pg-url-env',
'LOCAL_PG_URL',
'--embedding-module',
'./embed-memory.mjs',
]),
{
apply: true,
limit: 25,
cursor: { updatedAt: 12345, id: 'mem-9' },
tableName: 'memory_embeddings_local',
mysqlUrlEnv: 'LOCAL_MYSQL_URL',
pgUrlEnv: 'LOCAL_PG_URL',
embeddingModule: './embed-memory.mjs',
help: false,
},
);
});
test('runMemoryPgvectorBackfillCli dry-run scans MySQL and does not open PostgreSQL', async () => {
const closed = { mysql: false, pg: false };
let pgOpened = false;
const lines = [];
const originalLog = console.log;
console.log = (line = '') => {
lines.push(String(line));
};
try {
const result = await runMemoryPgvectorBackfillCli(
['--limit', '10'],
{ MEMORY_BACKFILL_MYSQL_URL: 'mysql://local' },
{
createMysqlPool: async () => createMysqlPool(rows, closed),
createPgPool: async () => {
pgOpened = true;
return createPgPool([], closed);
},
},
);
assert.equal(result.mode, 'dry-run');
assert.equal(result.scanned, 1);
assert.equal(result.inserted, 0);
assert.equal(pgOpened, false);
assert.equal(closed.mysql, true);
assert.match(lines.join('\n'), /"mode": "dry-run"/);
} finally {
console.log = originalLog;
}
});
test('runMemoryPgvectorBackfillCli apply requires pg url and embedding module', async () => {
await assert.rejects(
() => runMemoryPgvectorBackfillCli(
['--apply'],
{ MEMORY_BACKFILL_MYSQL_URL: 'mysql://local' },
{ createMysqlPool: async () => createMysqlPool(rows, {}) },
),
/MEMORY_PGVECTOR_DATABASE_URL/,
);
await assert.rejects(
() => runMemoryPgvectorBackfillCli(
['--apply'],
{
MEMORY_BACKFILL_MYSQL_URL: 'mysql://local',
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local',
},
{ createMysqlPool: async () => createMysqlPool(rows, {}) },
),
/--embedding-module/,
);
});
test('runMemoryPgvectorBackfillCli apply embeds and closes both pools', async () => {
const pgCalls = [];
const closed = { mysql: false, pg: false };
const originalLog = console.log;
console.log = () => {};
try {
const result = await runMemoryPgvectorBackfillCli(
['--apply', '--embedding-module', './embed-memory.mjs'],
{
MEMORY_BACKFILL_MYSQL_URL: 'mysql://local',
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local',
},
{
createMysqlPool: async () => createMysqlPool(rows, closed),
createPgPool: async () => createPgPool(pgCalls, closed),
loadEmbedMemory: async () => async () => [0.1, 0.2, 0.3],
},
);
assert.equal(result.mode, 'apply');
assert.equal(result.inserted, 1);
assert.equal(pgCalls.length, 1);
assert.equal(closed.mysql, true);
assert.equal(closed.pg, true);
} finally {
console.log = originalLog;
}
});
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env node
function normalizeBaseUrl(value) {
return String(value ?? '').trim().replace(/\/+$/, '');
}
function requireValue(name, value) {
if (!value) throw new Error(`${name} requires a value`);
return value;
}
export function parseMemoryV2AppCanaryArgs(argv = [], env = process.env) {
const options = {
baseUrl: normalizeBaseUrl(env.MEMORY_V2_APP_BASE_URL || 'http://127.0.0.1:8081'),
requireEnabled: false,
requireTargetHealthy: false,
expectedBackend: null,
expectedSelectedBackend: null,
timeoutMs: Number(env.MEMORY_V2_APP_CANARY_TIMEOUT_MS || 8000),
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--base-url') {
options.baseUrl = normalizeBaseUrl(requireValue(arg, argv[++i]));
} else if (arg === '--require-enabled') {
options.requireEnabled = true;
} else if (arg === '--require-target-healthy') {
options.requireTargetHealthy = true;
} else if (arg === '--expect-backend') {
options.expectedBackend = requireValue(arg, argv[++i]);
} else if (arg === '--expect-selected-backend') {
options.expectedSelectedBackend = requireValue(arg, argv[++i]);
} else if (arg === '--timeout-ms') {
options.timeoutMs = Number(requireValue(arg, argv[++i]));
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.baseUrl) throw new Error('--base-url is required');
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error('--timeout-ms must be a positive number');
}
return options;
}
function usage() {
return `Usage:
node scripts/check-memory-v2-app-canary.mjs [options]
Options:
--base-url <url> Portal base URL. Defaults to MEMORY_V2_APP_BASE_URL or http://127.0.0.1:8081.
--require-enabled Fail unless /api/runtime/status reports memory.enabled=true.
--expect-backend <name> Fail unless memory.backend equals this value.
--expect-selected-backend <name> Fail unless memory.selectedBackend equals this value.
--require-target-healthy Fail unless at least one runtime target is healthy.
--timeout-ms <ms> Request timeout. Defaults to 8000.
`;
}
async function fetchJson(fetchImpl, url, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, { signal: controller.signal });
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return {
ok: response.ok,
status: response.status,
text,
json,
};
} finally {
clearTimeout(timer);
}
}
function check(name, ok, details = {}) {
return { name, ok: Boolean(ok), ...details };
}
export async function runMemoryV2AppCanaryCli({
argv = process.argv.slice(2),
env = process.env,
stdout = process.stdout,
fetchImpl = globalThis.fetch,
} = {}) {
const options = parseMemoryV2AppCanaryArgs(argv, env);
if (options.help) {
stdout.write(usage());
return 0;
}
if (typeof fetchImpl !== 'function') {
throw new Error('fetch is not available in this Node.js runtime');
}
const runtime = await fetchJson(fetchImpl, `${options.baseUrl}/api/runtime/status`, options.timeoutMs);
const apiStatus = await fetchJson(fetchImpl, `${options.baseUrl}/api/status`, options.timeoutMs);
const authStatus = await fetchJson(fetchImpl, `${options.baseUrl}/auth/status`, options.timeoutMs);
const memory = runtime.json?.memory ?? null;
const targets = Array.isArray(runtime.json?.targets) ? runtime.json.targets : [];
const healthyTargets = targets.filter((target) => target?.healthy);
const checks = [
check('runtime_status_http_ok', runtime.ok, { status: runtime.status }),
check('runtime_status_ok', runtime.json?.ok === true),
check('memory_status_present', Boolean(memory)),
check('api_status_http_ok', apiStatus.ok, { status: apiStatus.status }),
check('auth_status_http_ok', authStatus.ok, { status: authStatus.status }),
];
if (options.requireEnabled) {
checks.push(check('memory_enabled', memory?.enabled === true, { actual: memory?.enabled ?? null }));
}
if (options.expectedBackend) {
checks.push(check('memory_backend', memory?.backend === options.expectedBackend, {
expected: options.expectedBackend,
actual: memory?.backend ?? null,
}));
}
if (options.expectedSelectedBackend) {
checks.push(check('memory_selected_backend', memory?.selectedBackend === options.expectedSelectedBackend, {
expected: options.expectedSelectedBackend,
actual: memory?.selectedBackend ?? null,
}));
}
if (options.requireTargetHealthy) {
checks.push(check('runtime_target_healthy', healthyTargets.length > 0, {
healthyTargets: healthyTargets.length,
targets: targets.length,
}));
}
const report = {
ok: checks.every((item) => item.ok),
baseUrl: options.baseUrl,
summary: {
memory: memory
? {
enabled: memory.enabled,
backend: memory.backend,
selectedBackend: memory.selectedBackend,
failOpen: memory.failOpen,
vectorEnabled: memory.vectorEnabled,
}
: null,
targets: {
total: targets.length,
healthy: healthyTargets.length,
},
apiStatus: {
status: apiStatus.status,
body: apiStatus.text.slice(0, 80),
},
authStatus: {
status: authStatus.status,
authenticated: authStatus.json?.authenticated ?? null,
mode: authStatus.json?.mode ?? null,
},
},
checks,
};
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return report.ok ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryV2AppCanaryCli().then((code) => {
process.exitCode = code;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
+130
View File
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseMemoryV2AppCanaryArgs,
runMemoryV2AppCanaryCli,
} from './check-memory-v2-app-canary.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
test('parseMemoryV2AppCanaryArgs maps app-level gate options', () => {
assert.deepEqual(
parseMemoryV2AppCanaryArgs([
'--base-url',
'http://127.0.0.1:18081/',
'--require-enabled',
'--require-target-healthy',
'--expect-backend',
'pgvector',
'--expect-selected-backend',
'pgvector',
'--timeout-ms',
'1000',
]),
{
baseUrl: 'http://127.0.0.1:18081',
requireEnabled: true,
requireTargetHealthy: true,
expectedBackend: 'pgvector',
expectedSelectedBackend: 'pgvector',
timeoutMs: 1000,
help: false,
},
);
});
test('runMemoryV2AppCanaryCli passes against a pgvector-enabled runtime status', async () => {
const stdout = writableBuffer();
const code = await runMemoryV2AppCanaryCli({
argv: [
'--base-url',
'http://app.local',
'--require-enabled',
'--require-target-healthy',
'--expect-backend',
'pgvector',
'--expect-selected-backend',
'pgvector',
],
stdout,
async fetchImpl(url) {
if (url.endsWith('/api/runtime/status')) {
return {
ok: true,
status: 200,
async text() {
return JSON.stringify({
ok: true,
memory: {
enabled: true,
backend: 'pgvector',
selectedBackend: 'pgvector',
failOpen: true,
vectorEnabled: true,
},
targets: [{ target: 'https://127.0.0.1:18006', healthy: true }],
});
},
};
}
if (url.endsWith('/api/status')) {
return { ok: true, status: 200, async text() { return 'ok'; } };
}
if (url.endsWith('/auth/status')) {
return {
ok: true,
status: 200,
async text() {
return JSON.stringify({ authenticated: false, mode: 'user' });
},
};
}
throw new Error(`unexpected url ${url}`);
},
});
const report = JSON.parse(stdout.value());
assert.equal(code, 0);
assert.equal(report.ok, true);
assert.equal(report.summary.memory.selectedBackend, 'pgvector');
assert.equal(report.summary.targets.healthy, 1);
});
test('runMemoryV2AppCanaryCli fails when selected backend does not match', async () => {
const stdout = writableBuffer();
const code = await runMemoryV2AppCanaryCli({
argv: ['--expect-selected-backend', 'pgvector'],
stdout,
async fetchImpl(url) {
if (url.endsWith('/api/runtime/status')) {
return {
ok: true,
status: 200,
async text() {
return JSON.stringify({
ok: true,
memory: { enabled: true, backend: 'pgvector', selectedBackend: 'legacy-conversation-memory' },
targets: [],
});
},
};
}
return { ok: true, status: 200, async text() { return '{}'; } };
},
});
const report = JSON.parse(stdout.value());
assert.equal(code, 1);
assert.equal(report.ok, false);
assert.equal(report.checks.find((item) => item.name === 'memory_selected_backend').ok, false);
});
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env node
const BACKEND_SPECS = [
{
name: 'pgvector',
env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_VECTOR_ENABLED', 'MEMORY_PGVECTOR_DATABASE_URL', 'MEMORY_PGVECTOR_EMBEDDING_MODULE'],
recommended: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'pgvector',
MEMORY_VECTOR_ENABLED: '1',
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://john@127.0.0.1:5432/memind_memory',
MEMORY_PGVECTOR_EMBEDDING_MODULE: './scripts/embed-memory-v2-local-hash.mjs',
},
},
{
name: 'qdrant',
env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_QDRANT_ENABLED', 'MEMORY_QDRANT_URL', 'MEMORY_QDRANT_EMBEDDING_MODULE'],
recommended: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'qdrant',
MEMORY_QDRANT_ENABLED: '1',
MEMORY_QDRANT_URL: 'http://127.0.0.1:6333',
MEMORY_QDRANT_EMBEDDING_MODULE: './scripts/embed-memory-v2-local-hash.mjs',
},
},
{
name: 'weaviate',
env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_WEAVIATE_ENABLED', 'MEMORY_WEAVIATE_URL', 'MEMORY_WEAVIATE_EMBEDDING_MODULE'],
recommended: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'weaviate',
MEMORY_WEAVIATE_ENABLED: '1',
MEMORY_WEAVIATE_URL: 'http://127.0.0.1:8080',
MEMORY_WEAVIATE_EMBEDDING_MODULE: './scripts/embed-memory-v2-local-hash.mjs',
},
},
{
name: 'mem0',
env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_MEM0_ENABLED', 'MEMORY_MEM0_API_KEY'],
recommended: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'mem0',
MEMORY_MEM0_ENABLED: '1',
MEMORY_MEM0_API_KEY: 'replace-me',
},
},
{
name: 'letta',
env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_LETTA_ENABLED', 'MEMORY_LETTA_API_KEY', 'MEMORY_LETTA_AGENT_ID'],
recommended: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'letta',
MEMORY_LETTA_ENABLED: '1',
MEMORY_LETTA_API_KEY: 'replace-me',
MEMORY_LETTA_AGENT_ID: 'agent_1',
},
},
{
name: 'neo4j',
env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_NEO4J_ENABLED', 'MEMORY_NEO4J_HTTP_URL', 'MEMORY_NEO4J_USER', 'MEMORY_NEO4J_PASSWORD'],
recommended: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'neo4j',
MEMORY_NEO4J_ENABLED: '1',
MEMORY_NEO4J_HTTP_URL: 'http://127.0.0.1:7474',
MEMORY_NEO4J_USER: 'neo4j',
MEMORY_NEO4J_PASSWORD: 'replace-me',
},
},
{
name: 'redis-streams',
env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_REDIS_STREAMS_ENABLED', 'MEMORY_REDIS_STREAMS_URL'],
recommended: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'redis-streams',
MEMORY_REDIS_STREAMS_ENABLED: '1',
MEMORY_REDIS_STREAMS_URL: 'redis://127.0.0.1:6379',
},
},
{
name: 'langgraph',
env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_LANGGRAPH_ENABLED', 'MEMORY_LANGGRAPH_URL'],
recommended: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'langgraph',
MEMORY_LANGGRAPH_ENABLED: '1',
MEMORY_LANGGRAPH_URL: 'http://127.0.0.1:2024',
},
},
];
function usage() {
return `Usage:
node scripts/check-memory-v2-config-gaps.mjs [options]
Options:
--backend <name> Limit output to one backend.
--format <type> json | shell. Default: json.
-h, --help Show this help.
`;
}
export function parseMemoryV2ConfigGapArgs(argv = []) {
const options = {
backend: null,
format: 'json',
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--backend') {
options.backend = argv[++i] ?? '';
} else if (arg === '--format') {
options.format = argv[++i] ?? '';
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (options.backend && !BACKEND_SPECS.some((item) => item.name === options.backend)) {
throw new Error(`--backend must be one of: ${BACKEND_SPECS.map((item) => item.name).join(', ')}`);
}
if (!['json', 'shell'].includes(options.format)) {
throw new Error('--format must be json or shell');
}
return options;
}
function inspectBackend(spec, env = process.env) {
const present = {};
const missing = [];
for (const key of spec.env) {
const value = String(env[key] ?? '').trim();
if (value) present[key] = value;
else missing.push(key);
}
return {
backend: spec.name,
ready: missing.length === 0,
missing,
presentKeys: Object.keys(present),
recommended: spec.recommended,
};
}
function renderShell(reports) {
const chunks = [];
for (const report of reports) {
chunks.push(`# ${report.backend}`);
for (const [key, value] of Object.entries(report.recommended)) {
chunks.push(`export ${key}='${String(value).replaceAll("'", "'\\''")}'`);
}
chunks.push('');
}
return chunks.join('\n').trimEnd();
}
export async function runMemoryV2ConfigGapCli({
argv = process.argv.slice(2),
env = process.env,
stdout = process.stdout,
} = {}) {
const options = parseMemoryV2ConfigGapArgs(argv);
if (options.help) {
stdout.write(usage());
return 0;
}
const reports = BACKEND_SPECS
.filter((spec) => !options.backend || spec.name === options.backend)
.map((spec) => inspectBackend(spec, env));
if (options.format === 'shell') {
stdout.write(`${renderShell(reports)}\n`);
return reports.every((item) => item.ready) ? 0 : 1;
}
const output = {
ok: reports.every((item) => item.ready),
checkedAt: new Date().toISOString(),
reports,
};
stdout.write(`${JSON.stringify(output, null, 2)}\n`);
return output.ok ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryV2ConfigGapCli().then((code) => {
process.exitCode = code;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseMemoryV2ConfigGapArgs,
runMemoryV2ConfigGapCli,
} from './check-memory-v2-config-gaps.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
test('parseMemoryV2ConfigGapArgs validates options', () => {
assert.deepEqual(
parseMemoryV2ConfigGapArgs(['--backend', 'qdrant', '--format', 'shell']),
{ backend: 'qdrant', format: 'shell', help: false },
);
assert.throws(() => parseMemoryV2ConfigGapArgs(['--backend', 'bad']), /--backend must be one of/);
assert.throws(() => parseMemoryV2ConfigGapArgs(['--format', 'xml']), /--format must be json or shell/);
});
test('runMemoryV2ConfigGapCli reports missing env vars in json mode', async () => {
const stdout = writableBuffer();
const code = await runMemoryV2ConfigGapCli({
argv: ['--backend', 'mem0'],
env: {},
stdout,
});
const report = JSON.parse(stdout.value());
assert.equal(code, 1);
assert.equal(report.ok, false);
assert.equal(report.reports[0].backend, 'mem0');
assert.ok(report.reports[0].missing.includes('MEMORY_MEM0_API_KEY'));
});
test('runMemoryV2ConfigGapCli emits export template in shell mode', async () => {
const stdout = writableBuffer();
const code = await runMemoryV2ConfigGapCli({
argv: ['--backend', 'qdrant', '--format', 'shell'],
env: {},
stdout,
});
assert.equal(code, 1);
assert.match(stdout.value(), /export MEMORY_BACKEND='qdrant'/);
assert.match(stdout.value(), /export MEMORY_QDRANT_URL='http:\/\/127\.0\.0\.1:6333'/);
});
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
import { createLegacyMemoryBackend } from '../memory-v2.mjs';
import { createMemoryV2PluginBackends } from '../memory-v2-plugin-backends.mjs';
import { createPgvectorMemoryBackend } from '../memory-v2-pgvector.mjs';
import { validateMemoryV2BackendSet } from '../memory-v2-backend-contract.mjs';
function usage() {
return `Usage:
node scripts/check-memory-v2-contracts.mjs [options]
Options:
--include-pgvector-adapter Include the real disabled pgvector adapter instead of its placeholder.
-h, --help Show this help.
`;
}
export function parseMemoryV2ContractArgs(argv = []) {
const options = {
includePgvectorAdapter: false,
help: false,
};
for (const arg of argv) {
if (arg === '--include-pgvector-adapter') {
options.includePgvectorAdapter = true;
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function createSyntheticLegacyMemoryService() {
return {
async listMemories() {
return [];
},
async saveAndAnalyze() {
return { saved: 0, analyzed: 0, memories: 0 };
},
async analyzeUser() {
return { analyzed: 0, memories: 0 };
},
};
}
export async function runMemoryV2ContractCli({
argv = process.argv.slice(2),
stdout = process.stdout,
} = {}) {
const options = parseMemoryV2ContractArgs(argv);
if (options.help) {
stdout.write(usage());
return 0;
}
const backends = [
createLegacyMemoryBackend(createSyntheticLegacyMemoryService()),
];
if (options.includePgvectorAdapter) {
backends.push(createPgvectorMemoryBackend({ enabled: false }));
}
backends.push(...createMemoryV2PluginBackends({
exclude: options.includePgvectorAdapter ? ['pgvector'] : [],
}));
const report = validateMemoryV2BackendSet(backends);
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return report.ok ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryV2ContractCli().then((code) => {
process.exitCode = code;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseMemoryV2ContractArgs,
runMemoryV2ContractCli,
} from './check-memory-v2-contracts.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
test('parseMemoryV2ContractArgs parses options', () => {
assert.deepEqual(parseMemoryV2ContractArgs(['--include-pgvector-adapter']), {
includePgvectorAdapter: true,
help: false,
});
assert.deepEqual(parseMemoryV2ContractArgs(['--help']), {
includePgvectorAdapter: false,
help: true,
});
});
test('runMemoryV2ContractCli passes for default backend contracts', async () => {
const stdout = writableBuffer();
const code = await runMemoryV2ContractCli({ stdout });
const report = JSON.parse(stdout.value());
assert.equal(code, 0);
assert.equal(report.ok, true);
assert.equal(report.summary.backendCount, 9);
assert.equal(report.summary.availableBackends[0], 'legacy-conversation-memory');
});
test('runMemoryV2ContractCli passes with real disabled pgvector adapter', async () => {
const stdout = writableBuffer();
const code = await runMemoryV2ContractCli({
argv: ['--include-pgvector-adapter'],
stdout,
});
const report = JSON.parse(stdout.value());
assert.equal(code, 0);
assert.equal(report.ok, true);
assert.equal(report.backends.find((backend) => backend.name === 'pgvector').category, 'semantic');
});
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { createMemoryV2Runtime } from '../memory-v2-runtime.mjs';
import { evaluateMemoryV2Health } from '../memory-v2-health.mjs';
function loadEnvFile(filePath, env = process.env) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx < 0) continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (!env[key]) env[key] = value;
}
}
export function parseMemoryV2HealthArgs(argv = []) {
const options = {
requireEnabled: false,
expectedBackend: null,
envFile: process.env.MEMIND_ENV_FILE || path.join(process.cwd(), '.env'),
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--require-enabled') {
options.requireEnabled = true;
} else if (arg === '--expect-backend') {
options.expectedBackend = argv[++i] ?? '';
} else if (arg === '--no-env-file') {
options.envFile = '';
} else if (arg === '--env-file') {
options.envFile = argv[++i] ?? '';
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function usage() {
return `Usage:
node scripts/check-memory-v2-health.mjs [options]
Options:
--require-enabled Fail unless Memory V2 is enabled.
--expect-backend <name> Fail unless MEMORY_BACKEND equals this value.
--env-file <path> Env file to load. Defaults to MEMIND_ENV_FILE or .env.
--no-env-file Do not load an env file.
`;
}
function createSyntheticLegacyMemoryService() {
return {
async listMemories() {
return [{ label: 'health', text: 'synthetic memory-v2 health check' }];
},
async saveAndAnalyze() {
return { saved: 1, analyzed: 1, memories: 1 };
},
async analyzeUser() {
return { analyzed: 1, memories: 1 };
},
};
}
export async function runMemoryV2HealthCli({
argv = process.argv.slice(2),
env = process.env,
stdout = process.stdout,
stderr = process.stderr,
importPg,
importModule,
} = {}) {
const options = parseMemoryV2HealthArgs(argv);
if (options.help) {
stdout.write(usage());
return 0;
}
if (options.envFile) loadEnvFile(options.envFile, env);
const memory = await createMemoryV2Runtime({
legacyMemoryService: createSyntheticLegacyMemoryService(),
env,
logger: {
warn(message) {
stderr.write(`${message}\n`);
},
},
...(importPg ? { importPg } : {}),
...(importModule ? { importModule } : {}),
});
const status = memory.getStatus();
const writeResult = await memory.write({
userId: 'memory-v2-health-user',
sessionId: 'memory-v2-health-session',
messages: [],
});
const compactResult = await memory.compact({
userId: 'memory-v2-health-user',
sessionId: 'memory-v2-health-session',
});
await memory.close?.();
const report = evaluateMemoryV2Health({
status,
writeResult,
compactResult,
expectedBackend: options.expectedBackend || null,
requireEnabled: options.requireEnabled,
});
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return report.ok ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryV2HealthCli().then((code) => {
process.exitCode = code;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseMemoryV2HealthArgs, runMemoryV2HealthCli } from './check-memory-v2-health.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
test('parseMemoryV2HealthArgs parses release gate options', () => {
assert.deepEqual(
parseMemoryV2HealthArgs([
'--require-enabled',
'--expect-backend',
'legacy',
'--no-env-file',
'--env-file',
'.env.memory',
]),
{
requireEnabled: true,
expectedBackend: 'legacy',
envFile: '.env.memory',
},
);
});
test('runMemoryV2HealthCli passes with default legacy-safe runtime', async () => {
const stdout = writableBuffer();
const stderr = writableBuffer();
const code = await runMemoryV2HealthCli({
argv: ['--require-enabled', '--expect-backend', 'legacy', '--no-env-file'],
env: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'legacy',
MEMORY_FAIL_OPEN: '1',
},
stdout,
stderr,
});
const report = JSON.parse(stdout.value());
assert.equal(code, 0);
assert.equal(report.ok, true);
assert.equal(report.summary.backend, 'legacy');
assert.equal(stderr.value(), '');
});
test('runMemoryV2HealthCli fails when enabled is required but disabled', async () => {
const stdout = writableBuffer();
const code = await runMemoryV2HealthCli({
argv: ['--require-enabled', '--no-env-file'],
env: {
MEMORY_ENABLED: '0',
MEMORY_BACKEND: 'legacy',
},
stdout,
stderr: writableBuffer(),
});
const report = JSON.parse(stdout.value());
assert.equal(code, 1);
assert.equal(report.ok, false);
assert.equal(report.checks.find((check) => check.name === 'memory_enabled').ok, false);
});
test('runMemoryV2HealthCli verifies pgvector resolve canary keeps legacy writes', async () => {
const stdout = writableBuffer();
let poolEnded = false;
class FakePool {
async query() {
return { rows: [] };
}
async end() {
poolEnded = true;
}
}
const code = await runMemoryV2HealthCli({
argv: ['--require-enabled', '--expect-backend', 'pgvector', '--no-env-file'],
env: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'pgvector',
MEMORY_VECTOR_ENABLED: '1',
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs',
},
stdout,
stderr: writableBuffer(),
async importPg() {
return { Pool: FakePool };
},
async importModule() {
return {
async embedQuery() {
return [0.1, 0.2, 0.3];
},
};
},
});
const report = JSON.parse(stdout.value());
assert.equal(code, 0);
assert.equal(poolEnded, true);
assert.equal(report.summary.selectedBackend, 'pgvector');
assert.equal(report.checks.find((check) => check.name === 'write_uses_legacy').ok, true);
assert.equal(report.checks.find((check) => check.name === 'compact_uses_legacy').ok, true);
});
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
function normalizeBaseUrl(value) {
return String(value ?? '').trim().replace(/\/+$/, '');
}
function requireValue(name, value) {
if (!value) throw new Error(`${name} requires a value`);
return value;
}
export function parseMemoryV2SessionFlowArgs(argv = [], env = process.env) {
const options = {
baseUrl: normalizeBaseUrl(env.MEMORY_V2_SESSION_FLOW_BASE_URL || 'http://127.0.0.1:8081'),
prompt: env.MEMORY_V2_SESSION_FLOW_PROMPT || 'Please confirm Memory V2 session flow is working.',
expectedBackend: null,
expectedSelectedBackend: null,
timeoutMs: Number(env.MEMORY_V2_SESSION_FLOW_TIMEOUT_MS || 45000),
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--base-url') {
options.baseUrl = normalizeBaseUrl(requireValue(arg, argv[++i]));
} else if (arg === '--prompt') {
options.prompt = requireValue(arg, argv[++i]);
} else if (arg === '--expect-backend') {
options.expectedBackend = requireValue(arg, argv[++i]);
} else if (arg === '--expect-selected-backend') {
options.expectedSelectedBackend = requireValue(arg, argv[++i]);
} else if (arg === '--timeout-ms') {
options.timeoutMs = Number(requireValue(arg, argv[++i]));
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.baseUrl) throw new Error('--base-url is required');
if (!options.prompt) throw new Error('--prompt is required');
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error('--timeout-ms must be a positive number');
}
return options;
}
function usage() {
return `Usage:
node scripts/check-memory-v2-session-flow.mjs [options]
Options:
--base-url <url> Portal base URL. Defaults to MEMORY_V2_SESSION_FLOW_BASE_URL or http://127.0.0.1:8081.
--prompt <text> User prompt sent through the live session.
--expect-backend <name> Fail unless runtime memory.backend equals this value.
--expect-selected-backend <name> Fail unless runtime memory.selectedBackend equals this value.
--timeout-ms <ms> Request timeout. Defaults to 45000.
`;
}
function makeCheck(name, ok, details = {}) {
return { name, ok: Boolean(ok), ...details };
}
async function parseResponseBody(response) {
const contentType = response.headers?.get?.('content-type') ?? '';
const text = await response.text();
if (contentType.includes('application/json')) {
try {
return { text, json: text ? JSON.parse(text) : null };
} catch {
return { text, json: null };
}
}
try {
return { text, json: text ? JSON.parse(text) : null };
} catch {
return { text, json: null };
}
}
async function requestJson(fetchImpl, url, { method = 'GET', headers = {}, body, timeoutMs = 45000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const payload = await parseResponseBody(response);
return { ok: response.ok, status: response.status, ...payload, headers: response.headers };
} finally {
clearTimeout(timer);
}
}
function buildTempUser() {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
return {
username: `mv2_${suffix}`,
password: 'MemoryV2-Session-2026',
email: `mv2-${suffix}@example.test`,
displayName: `mv2_${suffix}`,
};
}
async function waitForSessionFinish(fetchImpl, baseUrl, sessionId, cookie, timeoutMs, runTrigger) {
const response = await fetchImpl(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
headers: {
Accept: 'text/event-stream',
Cookie: cookie,
},
});
if (!response.ok || !response.body) {
const payload = await parseResponseBody(response);
throw new Error(`session events failed: ${response.status} ${payload.text}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const seen = [];
const runId = await runTrigger();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed) continue;
seen.push(trimmed);
if (trimmed.includes('type":"Error"')) {
throw new Error(`session stream error: ${trimmed}`);
}
if (trimmed.includes('type":"Finish"')) {
await reader.cancel().catch(() => {});
return { runId, seen };
}
}
}
await reader.cancel().catch(() => {});
throw new Error(`session stream timeout after ${timeoutMs}ms`);
}
export async function runMemoryV2SessionFlowCli({
argv = process.argv.slice(2),
env = process.env,
stdout = process.stdout,
fetchImpl = globalThis.fetch,
} = {}) {
const options = parseMemoryV2SessionFlowArgs(argv, env);
if (options.help) {
stdout.write(usage());
return 0;
}
if (typeof fetchImpl !== 'function') {
throw new Error('fetch is not available in this Node.js runtime');
}
const checks = [];
const user = buildTempUser();
const register = await requestJson(fetchImpl, `${options.baseUrl}/auth/register`, {
method: 'POST',
body: user,
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('register_http_ok', register.ok, { status: register.status }));
if (!register.ok) {
const report = { ok: false, baseUrl: options.baseUrl, checks, error: register.json ?? register.text };
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return 1;
}
const login = await requestJson(fetchImpl, `${options.baseUrl}/auth/login`, {
method: 'POST',
body: { username: user.username, password: user.password },
timeoutMs: options.timeoutMs,
});
const cookie = login.headers?.get?.('set-cookie')?.split(';', 1)[0] ?? null;
checks.push(makeCheck('login_http_ok', login.ok && Boolean(cookie), {
status: login.status,
hasCookie: Boolean(cookie),
}));
if (!login.ok || !cookie) {
const report = { ok: false, baseUrl: options.baseUrl, checks, error: login.json ?? login.text };
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return 1;
}
const started = await requestJson(fetchImpl, `${options.baseUrl}/api/agent/start`, {
method: 'POST',
body: {},
headers: { Cookie: cookie },
timeoutMs: options.timeoutMs,
});
const sessionId = started.json?.id ?? null;
checks.push(makeCheck('agent_start_ok', started.ok && Boolean(sessionId), {
status: started.status,
sessionId,
}));
if (!started.ok || !sessionId) {
const report = { ok: false, baseUrl: options.baseUrl, checks, error: started.json ?? started.text };
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return 1;
}
const requestId = crypto.randomUUID();
const { runId, seen } = await waitForSessionFinish(
fetchImpl,
options.baseUrl,
sessionId,
cookie,
options.timeoutMs,
async () => {
const created = await requestJson(fetchImpl, `${options.baseUrl}/api/agent/runs`, {
method: 'POST',
headers: { Cookie: cookie },
body: {
session_id: sessionId,
request_id: requestId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text: options.prompt }],
metadata: { userVisible: true, agentVisible: true },
},
},
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('agent_run_created', created.ok && created.status === 202, {
status: created.status,
runId: created.json?.run?.id ?? null,
}));
if (!created.ok || created.status !== 202 || !created.json?.run?.id) {
throw new Error(`agent run failed: ${created.status} ${created.text}`);
}
return created.json.run.id;
},
);
checks.push(makeCheck('session_finish_seen', seen.some((chunk) => chunk.includes('type":"Finish"')), {
eventCount: seen.length,
}));
const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
headers: { Cookie: cookie },
timeoutMs: options.timeoutMs,
});
const userVisibleMessages = Array.isArray(detail.json?.conversation)
? detail.json.conversation.filter((message) => message?.metadata?.userVisible)
: [];
const assistantMessages = userVisibleMessages.filter((message) => message?.role === 'assistant');
checks.push(makeCheck('session_detail_assistant_reply', detail.ok && assistantMessages.length > 0, {
status: detail.status,
userVisibleCount: userVisibleMessages.length,
assistantCount: assistantMessages.length,
}));
const remember = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/remember-recent`, {
method: 'POST',
headers: { Cookie: cookie },
body: { sessionId },
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('remember_recent_ok', remember.ok && remember.json?.ok === true, {
status: remember.status,
}));
const sync = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/sync`, {
method: 'POST',
headers: { Cookie: cookie },
body: { sessionId },
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('sync_ok', sync.ok && sync.json?.ok === true, {
status: sync.status,
}));
const runtime = await requestJson(fetchImpl, `${options.baseUrl}/api/runtime/status`, {
timeoutMs: options.timeoutMs,
});
const memory = runtime.json?.memory ?? null;
if (options.expectedBackend) {
checks.push(makeCheck('memory_backend', memory?.backend === options.expectedBackend, {
expected: options.expectedBackend,
actual: memory?.backend ?? null,
}));
}
if (options.expectedSelectedBackend) {
checks.push(makeCheck('memory_selected_backend', memory?.selectedBackend === options.expectedSelectedBackend, {
expected: options.expectedSelectedBackend,
actual: memory?.selectedBackend ?? null,
}));
}
const report = {
ok: checks.every((item) => item.ok),
baseUrl: options.baseUrl,
user: user.username,
sessionId,
runId,
summary: {
assistantPreview: assistantMessages.at(-1)?.content?.[0]?.text?.slice?.(0, 200) ?? null,
remember: remember.json,
sync: sync.json,
runtimeMemory: memory
? {
enabled: memory.enabled,
backend: memory.backend,
selectedBackend: memory.selectedBackend,
failOpen: memory.failOpen,
vectorEnabled: memory.vectorEnabled,
}
: null,
sessionEventsTail: seen.slice(-4),
},
checks,
};
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return report.ok ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryV2SessionFlowCli().then((code) => {
process.exitCode = code;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseMemoryV2SessionFlowArgs,
runMemoryV2SessionFlowCli,
} from './check-memory-v2-session-flow.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
function jsonResponse(body, { status = 200, headers = {} } = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: {
get(name) {
return headers[name.toLowerCase()] ?? headers[name] ?? null;
},
},
async text() {
return JSON.stringify(body);
},
};
}
function sseResponse(chunks) {
const encoded = chunks.map((chunk) => new TextEncoder().encode(chunk));
return {
ok: true,
status: 200,
headers: {
get() {
return 'text/event-stream';
},
},
body: new ReadableStream({
start(controller) {
for (const chunk of encoded) controller.enqueue(chunk);
controller.close();
},
}),
async text() {
return '';
},
};
}
test('parseMemoryV2SessionFlowArgs maps session flow options', () => {
assert.deepEqual(
parseMemoryV2SessionFlowArgs([
'--base-url',
'http://127.0.0.1:18081/',
'--prompt',
'hello',
'--expect-backend',
'pgvector',
'--expect-selected-backend',
'pgvector',
'--timeout-ms',
'5000',
]),
{
baseUrl: 'http://127.0.0.1:18081',
prompt: 'hello',
expectedBackend: 'pgvector',
expectedSelectedBackend: 'pgvector',
timeoutMs: 5000,
help: false,
},
);
});
test('runMemoryV2SessionFlowCli passes against a mocked live session flow', async () => {
const stdout = writableBuffer();
const code = await runMemoryV2SessionFlowCli({
argv: [
'--base-url',
'http://app.local',
'--expect-backend',
'pgvector',
'--expect-selected-backend',
'pgvector',
],
stdout,
async fetchImpl(url, init = {}) {
if (url.endsWith('/auth/register')) {
return jsonResponse({ ok: true, user: { id: 'user-1' } });
}
if (url.endsWith('/auth/login')) {
return jsonResponse(
{ authenticated: true, user: { id: 'user-1' } },
{ headers: { 'set-cookie': 'session=abc; Path=/; HttpOnly' } },
);
}
if (url.endsWith('/api/agent/start')) {
return jsonResponse({ id: 'session-1' });
}
if (url.endsWith('/api/sessions/session-1/events')) {
return sseResponse([
'id: 1\ndata: {"type":"Message","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"metadata":{"userVisible":true}}}\n\n',
'id: 2\ndata: {"type":"Finish","reason":"stop"}\n\n',
]);
}
if (url.endsWith('/api/agent/runs')) {
assert.equal(init.method, 'POST');
return jsonResponse({ run: { id: 'run-1' } }, { status: 202 });
}
if (url.endsWith('/api/sessions/session-1')) {
return jsonResponse({
conversation: [
{
role: 'user',
metadata: { userVisible: true },
content: [{ type: 'text', text: 'hello' }],
},
{
role: 'assistant',
metadata: { userVisible: true },
content: [{ type: 'text', text: 'hi' }],
},
],
});
}
if (url.endsWith('/api/user-memory/v1/remember-recent')) {
return jsonResponse({ ok: true, analyzed: 1, memories: 0, totalMemories: 0, syncedToSession: true });
}
if (url.endsWith('/api/user-memory/v1/sync')) {
return jsonResponse({ ok: true, analyzed: 1, memories: 0, totalMemories: 0, syncedToSession: true });
}
if (url.endsWith('/api/runtime/status')) {
return jsonResponse({
ok: true,
memory: {
enabled: true,
backend: 'pgvector',
selectedBackend: 'pgvector',
failOpen: true,
vectorEnabled: true,
},
});
}
throw new Error(`unexpected url ${url}`);
},
});
const report = JSON.parse(stdout.value());
assert.equal(code, 0);
assert.equal(report.ok, true);
assert.equal(report.sessionId, 'session-1');
assert.equal(report.runId, 'run-1');
assert.equal(report.summary.runtimeMemory.selectedBackend, 'pgvector');
});
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { runMemoryV2AppCanaryCli } from './check-memory-v2-app-canary.mjs';
import { runMemoryV2ContractCli } from './check-memory-v2-contracts.mjs';
import { runMemoryV2HealthCli } from './check-memory-v2-health.mjs';
import { runMemoryV2SessionFlowCli } from './check-memory-v2-session-flow.mjs';
import { runMemoryV2ExternalSmokeCli } from './smoke-memory-v2-external.mjs';
import { runMemoryPgvectorSmokeCli } from './smoke-memory-v2-pgvector.mjs';
import { runMemoryQdrantSmokeCli } from './smoke-memory-v2-qdrant.mjs';
const DEFAULT_BASE_URL = 'http://127.0.0.1:8081';
function loadEnvFile(filePath, env = process.env) {
if (!filePath || !fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx < 0) continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (!env[key]) env[key] = value;
}
}
function normalizeBaseUrl(value) {
return String(value ?? '').trim().replace(/\/+$/, '');
}
function flag(value) {
const normalized = String(value ?? '').trim().toLowerCase();
return ['1', 'true', 'yes', 'on'].includes(normalized);
}
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
function parseJsonReport(text) {
try {
return text ? JSON.parse(text) : null;
} catch {
return null;
}
}
function check(name, ok, details = {}) {
return { name, ok: Boolean(ok), ...details };
}
function usage() {
return `Usage:
node scripts/check-memory-v2-stack.mjs [options]
Options:
--base-url <url> Portal base URL. Default: ${DEFAULT_BASE_URL}.
--env-file <path> Env file to load before checks. Default: MEMIND_ENV_FILE or .env.
--no-env-file Do not load an env file.
--skip-app-canary Skip /api/runtime/status app canary.
--skip-session-flow Skip real user session flow verification.
--skip-backend-smokes Skip pgvector / external backend smoke probes.
--expect-backend <name> Require memory.backend to match this value when app canary runs.
--expect-selected-backend <name> Require memory.selectedBackend to match this value when app canary runs.
--timeout-ms <ms> Timeout passed to live checks. Default: 90000.
-h, --help Show this help.
`;
}
export function parseMemoryV2StackArgs(argv = [], env = process.env) {
const options = {
baseUrl: normalizeBaseUrl(env.MEMORY_V2_STACK_BASE_URL || DEFAULT_BASE_URL),
envFile: env.MEMIND_ENV_FILE || path.join(process.cwd(), '.env'),
runAppCanary: true,
runSessionFlow: true,
runBackendSmokes: true,
expectedBackend: env.MEMORY_BACKEND || null,
expectedSelectedBackend: env.MEMORY_BACKEND || null,
timeoutMs: Number(env.MEMORY_V2_STACK_TIMEOUT_MS || 90000),
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--base-url') {
options.baseUrl = normalizeBaseUrl(argv[++i]);
} else if (arg === '--env-file') {
options.envFile = argv[++i] ?? '';
} else if (arg === '--no-env-file') {
options.envFile = '';
} else if (arg === '--skip-app-canary') {
options.runAppCanary = false;
} else if (arg === '--skip-session-flow') {
options.runSessionFlow = false;
} else if (arg === '--skip-backend-smokes') {
options.runBackendSmokes = false;
} else if (arg === '--expect-backend') {
options.expectedBackend = argv[++i] ?? '';
} else if (arg === '--expect-selected-backend') {
options.expectedSelectedBackend = argv[++i] ?? '';
} else if (arg === '--timeout-ms') {
options.timeoutMs = Number(argv[++i]);
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.help && !options.baseUrl && (options.runAppCanary || options.runSessionFlow)) {
throw new Error('--base-url is required for live checks');
}
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error('--timeout-ms must be a positive number');
}
return options;
}
function detectBackendPlans(env) {
return [
{
name: 'pgvector',
configured:
flag(env.MEMORY_VECTOR_ENABLED)
&& Boolean(String(env.MEMORY_PGVECTOR_DATABASE_URL ?? '').trim())
&& Boolean(String(env.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? '').trim()),
reason: 'requires MEMORY_VECTOR_ENABLED, MEMORY_PGVECTOR_DATABASE_URL, MEMORY_PGVECTOR_EMBEDDING_MODULE',
},
{
name: 'qdrant',
configured:
flag(env.MEMORY_QDRANT_ENABLED)
&& Boolean(String(env.MEMORY_QDRANT_URL ?? '').trim())
&& Boolean(String(env.MEMORY_QDRANT_EMBEDDING_MODULE ?? '').trim()),
reason: 'requires MEMORY_QDRANT_ENABLED, MEMORY_QDRANT_URL, MEMORY_QDRANT_EMBEDDING_MODULE',
},
{
name: 'weaviate',
configured:
flag(env.MEMORY_WEAVIATE_ENABLED)
&& Boolean(String(env.MEMORY_WEAVIATE_URL ?? '').trim())
&& Boolean(String(env.MEMORY_WEAVIATE_EMBEDDING_MODULE ?? '').trim()),
reason: 'requires MEMORY_WEAVIATE_ENABLED, MEMORY_WEAVIATE_URL, MEMORY_WEAVIATE_EMBEDDING_MODULE',
},
{
name: 'mem0',
configured:
flag(env.MEMORY_MEM0_ENABLED)
&& Boolean(String(env.MEMORY_MEM0_API_KEY ?? '').trim()),
reason: 'requires MEMORY_MEM0_ENABLED and MEMORY_MEM0_API_KEY',
},
{
name: 'letta',
configured:
flag(env.MEMORY_LETTA_ENABLED)
&& Boolean(String(env.MEMORY_LETTA_API_KEY ?? '').trim())
&& Boolean(String(env.MEMORY_LETTA_AGENT_ID ?? '').trim()),
reason: 'requires MEMORY_LETTA_ENABLED, MEMORY_LETTA_API_KEY, MEMORY_LETTA_AGENT_ID',
},
{
name: 'neo4j',
configured:
flag(env.MEMORY_NEO4J_ENABLED)
&& Boolean(String(env.MEMORY_NEO4J_HTTP_URL ?? env.MEMORY_NEO4J_URI ?? '').trim())
&& Boolean(String(env.MEMORY_NEO4J_USER ?? '').trim())
&& Boolean(String(env.MEMORY_NEO4J_PASSWORD ?? '').trim()),
reason: 'requires MEMORY_NEO4J_ENABLED, MEMORY_NEO4J_HTTP_URL or MEMORY_NEO4J_URI, MEMORY_NEO4J_USER, MEMORY_NEO4J_PASSWORD',
},
{
name: 'redis-streams',
configured:
flag(env.MEMORY_REDIS_STREAMS_ENABLED)
&& Boolean(String(env.MEMORY_REDIS_STREAMS_URL ?? '').trim()),
reason: 'requires MEMORY_REDIS_STREAMS_ENABLED and MEMORY_REDIS_STREAMS_URL',
},
{
name: 'langgraph',
configured:
flag(env.MEMORY_LANGGRAPH_ENABLED)
&& Boolean(String(env.MEMORY_LANGGRAPH_URL ?? '').trim()),
reason: 'requires MEMORY_LANGGRAPH_ENABLED and MEMORY_LANGGRAPH_URL',
},
];
}
async function runCliAndParse(executor) {
const stdout = writableBuffer();
const code = await executor(stdout);
const report = parseJsonReport(stdout.value());
return { code, report };
}
export async function runMemoryV2StackCli({
argv = process.argv.slice(2),
env = process.env,
stdout = process.stdout,
stderr = process.stderr,
runners = {},
} = {}) {
const options = parseMemoryV2StackArgs(argv, env);
if (options.help) {
stdout.write(usage());
return 0;
}
if (options.envFile) loadEnvFile(options.envFile, env);
const checks = [];
const sections = {};
const logProgress = (message) => {
stderr.write(`[memory-v2-stack] ${message}\n`);
};
logProgress('running contracts');
const contractResult = await runCliAndParse((buffer) =>
(runners.runContractCli ?? runMemoryV2ContractCli)({ argv: [], stdout: buffer }),
);
sections.contracts = contractResult.report;
checks.push(check('contracts', contractResult.code === 0, {
backendCount: contractResult.report?.summary?.backendCount ?? null,
}));
logProgress(`contracts ${contractResult.code === 0 ? 'ok' : 'failed'}`);
const healthArgs = ['--require-enabled'];
if (options.expectedBackend) healthArgs.push('--expect-backend', options.expectedBackend);
if (!options.envFile) healthArgs.push('--no-env-file');
else healthArgs.push('--env-file', options.envFile);
logProgress('running health');
const healthResult = await runCliAndParse((buffer) =>
(runners.runHealthCli ?? runMemoryV2HealthCli)({ argv: healthArgs, env, stdout: buffer }),
);
sections.health = healthResult.report;
checks.push(check('health', healthResult.code === 0, {
backend: healthResult.report?.summary?.backend ?? null,
selectedBackend: healthResult.report?.summary?.selectedBackend ?? null,
}));
logProgress(`health ${healthResult.code === 0 ? 'ok' : 'failed'}`);
if (options.runAppCanary) {
const appArgs = ['--base-url', options.baseUrl, '--require-enabled', '--require-target-healthy', '--timeout-ms', String(options.timeoutMs)];
if (options.expectedBackend) appArgs.push('--expect-backend', options.expectedBackend);
if (options.expectedSelectedBackend) {
appArgs.push('--expect-selected-backend', options.expectedSelectedBackend);
}
logProgress('running app canary');
const appResult = await runCliAndParse((buffer) =>
(runners.runAppCanaryCli ?? runMemoryV2AppCanaryCli)({ argv: appArgs, env, stdout: buffer }),
);
sections.appCanary = appResult.report;
checks.push(check('app_canary', appResult.code === 0, {
baseUrl: options.baseUrl,
}));
logProgress(`app canary ${appResult.code === 0 ? 'ok' : 'failed'}`);
}
if (options.runSessionFlow) {
const sessionArgs = ['--base-url', options.baseUrl, '--timeout-ms', String(options.timeoutMs)];
if (options.expectedBackend) sessionArgs.push('--expect-backend', options.expectedBackend);
if (options.expectedSelectedBackend) {
sessionArgs.push('--expect-selected-backend', options.expectedSelectedBackend);
}
logProgress('running session flow');
const sessionResult = await runCliAndParse((buffer) =>
(runners.runSessionFlowCli ?? runMemoryV2SessionFlowCli)({ argv: sessionArgs, env, stdout: buffer }),
);
sections.sessionFlow = sessionResult.report;
checks.push(check('session_flow', sessionResult.code === 0, {
sessionId: sessionResult.report?.sessionId ?? null,
}));
logProgress(`session flow ${sessionResult.code === 0 ? 'ok' : 'failed'}`);
}
const backendPlans = detectBackendPlans(env);
const backendReports = [];
if (options.runBackendSmokes) {
for (const plan of backendPlans) {
if (!plan.configured) {
backendReports.push({
backend: plan.name,
skipped: true,
reason: 'not_configured',
expectedConfig: plan.reason,
});
checks.push(check(`backend_${plan.name}`, true, { skipped: true }));
continue;
}
if (plan.name === 'pgvector') {
logProgress('running backend smoke: pgvector');
const runner = runners.runPgvectorSmokeCli ?? runMemoryPgvectorSmokeCli;
const report = await runner(
[],
env,
{ stdout: writableBuffer() },
).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) }));
backendReports.push({ backend: plan.name, report });
checks.push(check(`backend_${plan.name}`, report?.ok === true, {
error: report?.error ?? null,
}));
logProgress(`backend smoke pgvector ${report?.ok === true ? 'ok' : 'failed'}`);
continue;
}
if (plan.name === 'qdrant') {
logProgress('running backend smoke: qdrant');
const rawRunner = runners.runQdrantSmokeCli ?? runMemoryQdrantSmokeCli;
const runtimeRunner = runners.runExternalSmokeCli ?? runMemoryV2ExternalSmokeCli;
const rawReport = await rawRunner({
env,
stdout: writableBuffer(),
}).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) }));
const runtimeReport = await runtimeRunner({
argv: ['--backend', 'qdrant'],
env,
stdout: writableBuffer(),
}).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) }));
backendReports.push({ backend: plan.name, rawReport, runtimeReport });
checks.push(check(`backend_${plan.name}`, rawReport?.ok === true && runtimeReport?.ok === true, {
rawError: rawReport?.error ?? null,
runtimeError: runtimeReport?.error ?? null,
}));
logProgress(`backend smoke qdrant ${(rawReport?.ok === true && runtimeReport?.ok === true) ? 'ok' : 'failed'}`);
continue;
}
logProgress(`running backend smoke: ${plan.name}`);
const runtimeRunner = runners.runExternalSmokeCli ?? runMemoryV2ExternalSmokeCli;
const runtimeReport = await runtimeRunner({
argv: ['--backend', plan.name],
env,
stdout: writableBuffer(),
}).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) }));
backendReports.push({ backend: plan.name, runtimeReport });
checks.push(check(`backend_${plan.name}`, runtimeReport?.ok === true, {
error: runtimeReport?.error ?? null,
}));
logProgress(`backend smoke ${plan.name} ${runtimeReport?.ok === true ? 'ok' : 'failed'}`);
}
}
sections.backends = backendReports;
const report = {
ok: checks.every((item) => item.ok),
checkedAt: new Date().toISOString(),
summary: {
baseUrl: options.baseUrl,
expectedBackend: options.expectedBackend ?? null,
expectedSelectedBackend: options.expectedSelectedBackend ?? null,
configuredBackends: backendPlans.filter((item) => item.configured).map((item) => item.name),
skippedBackends: backendReports.filter((item) => item.skipped).map((item) => item.backend),
},
checks,
sections,
};
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return report.ok ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryV2StackCli().then((code) => {
process.exitCode = code;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { parseMemoryV2StackArgs, runMemoryV2StackCli } from './check-memory-v2-stack.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
test('parseMemoryV2StackArgs maps aggregate options', () => {
assert.deepEqual(
parseMemoryV2StackArgs([
'--base-url',
'http://127.0.0.1:18081/',
'--skip-session-flow',
'--skip-backend-smokes',
'--expect-backend',
'pgvector',
'--expect-selected-backend',
'pgvector',
'--timeout-ms',
'5000',
], {}),
{
baseUrl: 'http://127.0.0.1:18081',
envFile: path.join(process.cwd(), '.env'),
runAppCanary: true,
runSessionFlow: false,
runBackendSmokes: false,
expectedBackend: 'pgvector',
expectedSelectedBackend: 'pgvector',
timeoutMs: 5000,
help: false,
},
);
});
test('runMemoryV2StackCli aggregates configured and skipped backend checks', async () => {
const stdout = writableBuffer();
const code = await runMemoryV2StackCli({
argv: ['--skip-app-canary', '--skip-session-flow'],
env: {
MEMORY_BACKEND: 'pgvector',
MEMORY_VECTOR_ENABLED: '1',
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://db',
MEMORY_PGVECTOR_EMBEDDING_MODULE: './embed.mjs',
MEMORY_QDRANT_ENABLED: '1',
MEMORY_QDRANT_URL: 'http://qdrant.local',
MEMORY_QDRANT_EMBEDDING_MODULE: './embed.mjs',
},
stdout,
runners: {
async runContractCli({ stdout: buffer }) {
buffer.write(JSON.stringify({ ok: true, summary: { backendCount: 9 } }));
return 0;
},
async runHealthCli({ stdout: buffer }) {
buffer.write(JSON.stringify({ ok: true, summary: { backend: 'pgvector', selectedBackend: 'pgvector' } }));
return 0;
},
async runPgvectorSmokeCli() {
return { ok: true, source: 'pgvector-smoke' };
},
async runQdrantSmokeCli() {
return { ok: true, source: 'qdrant-raw' };
},
async runExternalSmokeCli({ argv: cliArgv }) {
return { ok: true, backend: cliArgv[1], source: 'runtime-smoke' };
},
},
});
const report = JSON.parse(stdout.value());
assert.equal(code, 0);
assert.equal(report.ok, true);
assert.deepEqual(report.summary.configuredBackends, ['pgvector', 'qdrant']);
assert.ok(report.summary.skippedBackends.includes('weaviate'));
assert.equal(report.checks.find((item) => item.name === 'contracts').ok, true);
assert.equal(report.checks.find((item) => item.name === 'health').ok, true);
assert.equal(report.checks.find((item) => item.name === 'backend_pgvector').ok, true);
assert.equal(report.checks.find((item) => item.name === 'backend_qdrant').ok, true);
});
+49
View File
@@ -0,0 +1,49 @@
const DEFAULT_DIMENSIONS = 3;
function resolveDimensions(value) {
const dimensions = Number(value ?? process.env.MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS ?? DEFAULT_DIMENSIONS);
if (!Number.isInteger(dimensions) || dimensions < 1 || dimensions > 4096) {
throw new Error(`Invalid MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS: ${value}`);
}
return dimensions;
}
function hashToken(token) {
let hash = 2166136261;
for (const char of String(token)) {
hash ^= char.codePointAt(0);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function tokenize(text) {
return String(text ?? '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, ' ')
.trim()
.split(/\s+/)
.filter(Boolean);
}
export function embedText(text, { dimensions = undefined } = {}) {
const resolvedDimensions = resolveDimensions(dimensions);
const vector = Array.from({ length: resolvedDimensions }, () => 0);
const tokens = tokenize(text);
for (const token of tokens.length ? tokens : ['empty']) {
const hash = hashToken(token);
const index = hash % resolvedDimensions;
const sign = hash & 1 ? 1 : -1;
vector[index] += sign * (1 + (token.length % 7));
}
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1;
return vector.map((value) => Number((value / norm).toFixed(8)));
}
export async function embedQuery(query, input = {}) {
return embedText(query, {
dimensions: input.dimensions ?? input.embeddingDimensions,
});
}
export default embedQuery;
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { embedQuery, embedText } from './embed-memory-v2-local-hash.mjs';
test('embedText returns deterministic normalized vectors', () => {
const first = embedText('memory chain local smoke', { dimensions: 8 });
const second = embedText('memory chain local smoke', { dimensions: 8 });
const norm = Math.sqrt(first.reduce((sum, value) => sum + value * value, 0));
assert.deepEqual(first, second);
assert.equal(first.length, 8);
assert.ok(Math.abs(norm - 1) < 0.000001);
});
test('embedQuery accepts dimensions from runtime input', async () => {
const vector = await embedQuery('pgvector canary', { dimensions: 3 });
assert.equal(vector.length, 3);
assert.equal(vector.every((item) => Number.isFinite(item)), true);
});
test('embedText validates dimensions', () => {
assert.throws(
() => embedText('bad', { dimensions: 0 }),
/Invalid MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS/,
);
});
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env node
import http from 'node:http';
import { URL } from 'node:url';
function readJson(req) {
return new Promise((resolve, reject) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
if (!body.trim()) {
resolve({});
return;
}
try {
resolve(JSON.parse(body));
} catch (err) {
reject(err);
}
});
req.on('error', reject);
});
}
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(payload));
}
export function createMockMemoryV2Service() {
const state = {
mem0: {
writes: [],
compacts: [],
},
letta: {
messages: [],
compacts: [],
},
langgraph: {
resolves: [],
},
};
function reset() {
state.mem0.writes.length = 0;
state.mem0.compacts.length = 0;
state.letta.messages.length = 0;
state.letta.compacts.length = 0;
state.langgraph.resolves.length = 0;
}
async function handle(req, res) {
const url = new URL(req.url, 'http://127.0.0.1');
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/health') {
sendJson(res, 200, { ok: true, service: 'mock-memory-v2-services' });
return;
}
if (req.method === 'POST' && pathname === '/__admin/reset') {
reset();
sendJson(res, 200, { ok: true });
return;
}
if (req.method === 'GET' && pathname === '/__admin/state') {
sendJson(res, 200, {
ok: true,
state,
});
return;
}
if (req.method === 'POST' && pathname === '/mem0/v1/memories') {
const body = await readJson(req);
state.mem0.writes.push(body);
sendJson(res, 200, {
saved: 1,
analyzed: 1,
memory_count: Array.isArray(body.messages) ? body.messages.length : 0,
});
return;
}
if (req.method === 'POST' && pathname === '/mem0/v1/memories/compact') {
const body = await readJson(req);
state.mem0.compacts.push(body);
sendJson(res, 200, {
analyzed: 1,
memory_count: state.mem0.writes.filter((item) => item.user_id === body.user_id).length,
});
return;
}
if (req.method === 'POST' && /^\/letta\/v1\/agents\/[^/]+\/messages$/.test(pathname)) {
const body = await readJson(req);
const agentId = pathname.split('/')[4];
state.letta.messages.push({ agentId, ...body });
sendJson(res, 200, {
saved: 1,
analyzed: 0,
memory_count: Array.isArray(body.messages) ? body.messages.length : 0,
});
return;
}
if (req.method === 'POST' && /^\/letta\/v1\/agents\/[^/]+\/memory\/compact$/.test(pathname)) {
const body = await readJson(req);
const agentId = pathname.split('/')[4];
state.letta.compacts.push({ agentId, ...body });
sendJson(res, 200, {
analyzed: 1,
memory_count: state.letta.messages.filter((item) => item.user_id === body.user_id).length,
});
return;
}
if (req.method === 'POST' && /^\/letta\/v1\/agents\/[^/]+\/memory$/.test(pathname)) {
const body = await readJson(req);
const agentId = pathname.split('/')[4];
const messages = state.letta.messages
.filter((item) => item.agentId === agentId && item.user_id === body.user_id)
.flatMap((item) => Array.isArray(item.messages) ? item.messages : [])
.map((item, index) => ({
id: `letta-${index + 1}`,
label: 'lifecycle',
text: String(item.text ?? item.content ?? '').trim() || `letta-memory-${index + 1}`,
}))
.filter((item) => item.text);
sendJson(res, 200, {
memories: messages.length ? messages : ['letta lifecycle memory'],
activeGoals: ['memory-v2'],
});
return;
}
if (req.method === 'POST' && pathname === '/langgraph/memory/resolve') {
const body = await readJson(req);
state.langgraph.resolves.push(body);
sendJson(res, 200, {
memories: [{
id: 'langgraph-1',
label: 'policy',
text: `langgraph route for ${body.user_id ?? 'unknown-user'}`,
}],
activeGoals: ['route-memory'],
behaviorSummary: null,
});
return;
}
sendJson(res, 404, { ok: false, message: 'not found' });
}
return {
state,
reset,
createServer() {
return http.createServer((req, res) => {
Promise.resolve(handle(req, res)).catch((err) => {
sendJson(res, 500, {
ok: false,
message: err instanceof Error ? err.message : String(err),
});
});
});
},
};
}
export async function startMockMemoryV2Service({
port = Number(process.env.MEMORY_V2_MOCK_PORT ?? 19400) || 19400,
host = process.env.MEMORY_V2_MOCK_HOST ?? '127.0.0.1',
stdout = process.stdout,
} = {}) {
const service = createMockMemoryV2Service();
const server = service.createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, resolve);
});
stdout.write(`${JSON.stringify({ ok: true, host, port }, null, 2)}\n`);
return { service, server, host, port };
}
if (import.meta.url === `file://${process.argv[1]}`) {
startMockMemoryV2Service().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { once } from 'node:events';
import { createMockMemoryV2Service } from './mock-memory-v2-services.mjs';
test('mock memory v2 service stores mem0, letta, and langgraph requests', async () => {
const service = createMockMemoryV2Service();
const server = service.createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address();
const baseUrl = `http://127.0.0.1:${port}`;
await fetch(`${baseUrl}/mem0/v1/memories`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ user_id: 'u1', messages: [{ text: 'hello mem0' }] }),
});
await fetch(`${baseUrl}/letta/v1/agents/agent_1/messages`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ user_id: 'u1', messages: [{ text: 'hello letta' }] }),
});
const langgraphResponse = await fetch(`${baseUrl}/langgraph/memory/resolve`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ user_id: 'u1', query: 'route me' }),
});
const langgraphPayload = await langgraphResponse.json();
const stateResponse = await fetch(`${baseUrl}/__admin/state`);
const statePayload = await stateResponse.json();
assert.equal(statePayload.state.mem0.writes.length, 1);
assert.equal(statePayload.state.letta.messages.length, 1);
assert.equal(statePayload.state.langgraph.resolves.length, 1);
assert.deepEqual(langgraphPayload.activeGoals, ['route-memory']);
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
});
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import {
normalizeMemoryV2AdapterScaffoldOptions,
renderMemoryV2AdapterScaffold,
} from '../memory-v2-adapter-scaffold.mjs';
function usage() {
return `Usage:
node scripts/scaffold-memory-v2-backend.mjs --name <backend> --category <category> --capability <op> [options]
Options:
--name <name> Backend name, normalized to kebab-case.
--category <category> semantic | extraction | lifecycle | behavior | policy.
--role <role> Backend role. Default: optional-plugin.
--capability <op> resolve | write | compact. Repeatable.
--flag <ENV_FLAG> Feature flag. Default: MEMORY_<NAME>_ENABLED.
--out <path> Output path. Default: memory-v2-<name>.mjs.
--write Write file. Default is dry-run to stdout.
-h, --help Show this help.
`;
}
export function parseMemoryV2ScaffoldArgs(argv = []) {
const options = {
name: null,
category: null,
role: 'optional-plugin',
capabilities: [],
flag: null,
out: null,
write: false,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--name') {
options.name = argv[++i] ?? '';
} else if (arg === '--category') {
options.category = argv[++i] ?? '';
} else if (arg === '--role') {
options.role = argv[++i] ?? '';
} else if (arg === '--capability') {
options.capabilities.push(argv[++i] ?? '');
} else if (arg === '--flag') {
options.flag = argv[++i] ?? '';
} else if (arg === '--out') {
options.out = argv[++i] ?? '';
} else if (arg === '--write') {
options.write = true;
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
export async function runMemoryV2ScaffoldCli({
argv = process.argv.slice(2),
stdout = process.stdout,
cwd = process.cwd(),
} = {}) {
const parsed = parseMemoryV2ScaffoldArgs(argv);
if (parsed.help) {
stdout.write(usage());
return { ok: true, mode: 'help' };
}
const normalized = normalizeMemoryV2AdapterScaffoldOptions(parsed);
const source = renderMemoryV2AdapterScaffold(normalized);
const outPath = parsed.out || `memory-v2-${normalized.name}.mjs`;
if (!parsed.write) {
stdout.write(source);
return { ok: true, mode: 'dry-run', outPath };
}
const absoluteOut = path.resolve(cwd, outPath);
await fs.mkdir(path.dirname(absoluteOut), { recursive: true });
try {
await fs.writeFile(absoluteOut, source, { encoding: 'utf8', flag: 'wx' });
} catch (err) {
if (err?.code === 'EEXIST') {
throw new Error(`Refusing to overwrite existing adapter: ${absoluteOut}`);
}
throw err;
}
stdout.write(`Memory V2 backend scaffold created: ${absoluteOut}\n`);
return { ok: true, mode: 'write', outPath: absoluteOut };
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryV2ScaffoldCli().then((result) => {
process.exitCode = result.ok ? 0 : 1;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
parseMemoryV2ScaffoldArgs,
runMemoryV2ScaffoldCli,
} from './scaffold-memory-v2-backend.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
test('parseMemoryV2ScaffoldArgs parses scaffold options', () => {
assert.deepEqual(
parseMemoryV2ScaffoldArgs([
'--name',
'qdrant',
'--category',
'semantic',
'--role',
'scale-out',
'--capability',
'resolve',
'--flag',
'MEMORY_QDRANT_ENABLED',
'--out',
'memory-v2-qdrant.mjs',
'--write',
]),
{
name: 'qdrant',
category: 'semantic',
role: 'scale-out',
capabilities: ['resolve'],
flag: 'MEMORY_QDRANT_ENABLED',
out: 'memory-v2-qdrant.mjs',
write: true,
help: false,
},
);
});
test('runMemoryV2ScaffoldCli dry-run prints adapter source', async () => {
const stdout = writableBuffer();
const result = await runMemoryV2ScaffoldCli({
argv: [
'--name',
'qdrant',
'--category',
'semantic',
'--capability',
'resolve',
],
stdout,
});
assert.equal(result.mode, 'dry-run');
assert.match(stdout.value(), /export function createQdrantMemoryBackend/);
assert.match(stdout.value(), /name: 'qdrant'/);
});
test('runMemoryV2ScaffoldCli writes new adapter and refuses overwrite', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-v2-scaffold-'));
const stdout = writableBuffer();
const argv = [
'--name',
'mem0',
'--category',
'extraction',
'--capability',
'write',
'--capability',
'compact',
'--write',
];
const result = await runMemoryV2ScaffoldCli({ argv, stdout, cwd: tmpDir });
const written = await fs.readFile(path.join(tmpDir, 'memory-v2-mem0.mjs'), 'utf8');
assert.equal(result.mode, 'write');
assert.match(stdout.value(), /Memory V2 backend scaffold created/);
assert.match(written, /export function createMem0MemoryBackend/);
await assert.rejects(
() => runMemoryV2ScaffoldCli({ argv, stdout: writableBuffer(), cwd: tmpDir }),
/Refusing to overwrite/,
);
});
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env node
import {
buildPgvectorMemorySchemaSql,
ensurePgvectorMemorySchema,
} from '../memory-v2-pgvector-schema.mjs';
const DEFAULT_URL_ENV = 'MEMORY_PGVECTOR_DATABASE_URL';
function usage() {
return `
Usage:
node scripts/setup-memory-v2-pgvector-schema.mjs [options]
Default mode is dry-run: print SQL only.
Options:
--apply Execute SQL against PostgreSQL.
--table <name> Table name. Default: memory_embeddings.
--dimensions <number> Embedding dimensions. Default: 1536.
--create-extension Include CREATE EXTENSION IF NOT EXISTS vector.
--create-vector-index Include ivfflat vector index creation.
--url-env <name> Env var containing PostgreSQL URL. Default: ${DEFAULT_URL_ENV}.
-h, --help Show this help.
`.trim();
}
export function parseMemoryPgvectorSchemaArgs(argv = []) {
const options = {
apply: false,
tableName: 'memory_embeddings',
dimensions: 1536,
createExtension: false,
createVectorIndex: false,
urlEnv: DEFAULT_URL_ENV,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
switch (arg) {
case '--apply':
options.apply = true;
break;
case '--create-extension':
options.createExtension = true;
break;
case '--create-vector-index':
options.createVectorIndex = true;
break;
case '--table':
i += 1;
options.tableName = argv[i];
break;
case '--dimensions':
i += 1;
options.dimensions = Number(argv[i]);
break;
case '--url-env':
i += 1;
options.urlEnv = argv[i];
break;
case '-h':
case '--help':
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.tableName) throw new Error('--table requires a value');
if (!Number.isFinite(options.dimensions)) throw new Error('--dimensions requires a number');
if (!options.urlEnv) throw new Error('--url-env requires a value');
return options;
}
function printSql(statements) {
for (const statement of statements) {
console.log(`${statement};\n`);
}
}
export async function runMemoryPgvectorSchemaCli(argv = process.argv.slice(2), env = process.env) {
const options = parseMemoryPgvectorSchemaArgs(argv);
if (options.help) {
console.log(usage());
return { ok: true, mode: 'help' };
}
const schemaOptions = {
tableName: options.tableName,
dimensions: options.dimensions,
createExtension: options.createExtension,
createVectorIndex: options.createVectorIndex,
};
const statements = buildPgvectorMemorySchemaSql(schemaOptions);
if (!options.apply) {
console.log('-- Memory V2 pgvector schema dry-run');
console.log('-- No database changes were made. Re-run with --apply to execute.');
printSql(statements);
return { ok: true, mode: 'dry-run', statements: statements.length };
}
const connectionString = env[options.urlEnv];
if (!connectionString) {
throw new Error(`--apply requires ${options.urlEnv} to be set`);
}
const { default: pg } = await import('pg');
const pool = new pg.Pool({ connectionString, max: 1 });
try {
const result = await ensurePgvectorMemorySchema(pool, schemaOptions);
console.log(
`Memory V2 pgvector schema ensured: table=${result.tableName}, dimensions=${result.dimensions}, statements=${result.statements}`,
);
return { ok: true, mode: 'apply', ...result };
} finally {
await pool.end();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryPgvectorSchemaCli().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseMemoryPgvectorSchemaArgs,
runMemoryPgvectorSchemaCli,
} from './setup-memory-v2-pgvector-schema.mjs';
test('parseMemoryPgvectorSchemaArgs defaults to dry-run safe settings', () => {
assert.deepEqual(parseMemoryPgvectorSchemaArgs([]), {
apply: false,
tableName: 'memory_embeddings',
dimensions: 1536,
createExtension: false,
createVectorIndex: false,
urlEnv: 'MEMORY_PGVECTOR_DATABASE_URL',
help: false,
});
});
test('parseMemoryPgvectorSchemaArgs maps explicit schema options', () => {
assert.deepEqual(
parseMemoryPgvectorSchemaArgs([
'--apply',
'--table',
'memory_embeddings_local',
'--dimensions',
'768',
'--create-extension',
'--create-vector-index',
'--url-env',
'LOCAL_MEMORY_PG_URL',
]),
{
apply: true,
tableName: 'memory_embeddings_local',
dimensions: 768,
createExtension: true,
createVectorIndex: true,
urlEnv: 'LOCAL_MEMORY_PG_URL',
help: false,
},
);
});
test('runMemoryPgvectorSchemaCli dry-run prints SQL without requiring database url', async () => {
const lines = [];
const originalLog = console.log;
console.log = (line = '') => {
lines.push(String(line));
};
try {
const result = await runMemoryPgvectorSchemaCli([
'--table',
'memory_embeddings',
'--dimensions',
'1536',
], {});
assert.deepEqual(result, { ok: true, mode: 'dry-run', statements: 3 });
assert.match(lines.join('\n'), /No database changes were made/);
assert.match(lines.join('\n'), /CREATE TABLE IF NOT EXISTS "memory_embeddings"/);
} finally {
console.log = originalLog;
}
});
test('runMemoryPgvectorSchemaCli apply requires explicit PostgreSQL url env', async () => {
await assert.rejects(
() => runMemoryPgvectorSchemaCli(['--apply'], {}),
/MEMORY_PGVECTOR_DATABASE_URL/,
);
});
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
import { createMemoryV2Runtime } from '../memory-v2-runtime.mjs';
const BACKENDS = new Set([
'qdrant',
'weaviate',
'mem0',
'letta',
'neo4j',
'redis-streams',
'langgraph',
]);
function usage() {
return `
Usage:
node scripts/smoke-memory-v2-external.mjs --backend <name> [options]
Runs a Memory V2 external backend smoke through the real runtime wiring.
Options:
--backend <name> qdrant | weaviate | mem0 | letta | neo4j | redis-streams | langgraph
--operation <name> resolve | write | compact. Default resolves per backend.
--query <text> Resolve query. Default: memory-chain smoke
--limit <number> Resolve limit. Default: 1
--vector <csv> Optional query embedding CSV for resolve.
--user-id <id> Smoke user id. Default: memory-v2-smoke-user
--session-id <id> Smoke session id. Default: memory-v2-smoke-session
-h, --help Show this help.
`.trim();
}
function parseVector(value) {
if (value == null || value === '') return null;
const vector = String(value).split(',').map((item) => Number(item.trim()));
if (!vector.length || vector.some((item) => !Number.isFinite(item))) {
throw new Error('--vector must be a comma-separated list of numbers');
}
return vector;
}
function defaultOperation(backend) {
if (backend === 'mem0' || backend === 'redis-streams') return 'write';
return 'resolve';
}
export function parseMemoryV2ExternalSmokeArgs(argv = []) {
const options = {
backend: null,
operation: null,
query: 'memory-chain smoke',
limit: 1,
vector: null,
userId: 'memory-v2-smoke-user',
sessionId: 'memory-v2-smoke-session',
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
switch (arg) {
case '--backend':
options.backend = argv[++i] ?? '';
break;
case '--operation':
options.operation = argv[++i] ?? '';
break;
case '--query':
options.query = argv[++i] ?? '';
break;
case '--limit':
options.limit = Number(argv[++i]);
break;
case '--vector':
options.vector = parseVector(argv[++i]);
break;
case '--user-id':
options.userId = argv[++i] ?? '';
break;
case '--session-id':
options.sessionId = argv[++i] ?? '';
break;
case '-h':
case '--help':
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (options.help) return options;
if (!BACKENDS.has(options.backend)) {
throw new Error(`--backend must be one of: ${[...BACKENDS].join(', ')}`);
}
options.operation ||= defaultOperation(options.backend);
if (!['resolve', 'write', 'compact'].includes(options.operation)) {
throw new Error('--operation must be resolve, write, or compact');
}
if (!options.query) throw new Error('--query requires a value');
if (!Number.isInteger(options.limit) || options.limit < 1) {
throw new Error('--limit requires a positive integer');
}
if (!options.userId) throw new Error('--user-id requires a value');
if (!options.sessionId) throw new Error('--session-id requires a value');
return options;
}
function createSyntheticLegacyMemoryService() {
return {
async listMemories() {
return [];
},
async saveAndAnalyze() {
return { saved: 0, analyzed: 0, memories: 0 };
},
async analyzeUser() {
return { analyzed: 0, memories: 0 };
},
};
}
async function runOperation(memory, options) {
const input = {
userId: options.userId,
sessionId: options.sessionId,
query: options.query,
embedding: options.vector,
limit: options.limit,
messages: [{
role: 'user',
text: options.query,
content: options.query,
}],
eventType: 'memory-v2.smoke',
};
if (options.operation === 'resolve') return memory.resolve(input);
if (options.operation === 'write') return memory.write(input);
return memory.compact(input);
}
export async function runMemoryV2ExternalSmokeCli({
argv = process.argv.slice(2),
env = process.env,
stdout = process.stdout,
fetchImpl = globalThis.fetch,
importRedis = (specifier) => import(specifier),
importModule = (specifier) => import(specifier),
} = {}) {
const options = parseMemoryV2ExternalSmokeArgs(argv);
if (options.help) {
stdout.write(`${usage()}\n`);
return { ok: true, mode: 'help' };
}
const memory = await createMemoryV2Runtime({
legacyMemoryService: createSyntheticLegacyMemoryService(),
env: {
...env,
MEMORY_ENABLED: '1',
MEMORY_BACKEND: options.backend,
},
logger: console,
fetchImpl,
importRedis,
importModule,
});
try {
const result = await runOperation(memory, options);
const backend = memory.getStatus().backends.find((item) => item.name === options.backend);
const report = {
ok: result?.ok !== false,
checkedAt: new Date().toISOString(),
backend: options.backend,
operation: options.operation,
selectedBackend: memory.getStatus().selectedBackend,
backendAvailable: backend?.available ?? false,
backendReason: backend?.reason ?? null,
result,
};
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return report;
} finally {
await memory.close?.();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryV2ExternalSmokeCli().then((result) => {
process.exitCode = result.ok ? 0 : 1;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
+175
View File
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseMemoryV2ExternalSmokeArgs,
runMemoryV2ExternalSmokeCli,
} from './smoke-memory-v2-external.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
test('parseMemoryV2ExternalSmokeArgs validates backend and defaults operation', () => {
assert.deepEqual(
parseMemoryV2ExternalSmokeArgs(['--backend', 'redis-streams']),
{
backend: 'redis-streams',
operation: 'write',
query: 'memory-chain smoke',
limit: 1,
vector: null,
userId: 'memory-v2-smoke-user',
sessionId: 'memory-v2-smoke-session',
help: false,
},
);
assert.deepEqual(
parseMemoryV2ExternalSmokeArgs([
'--backend',
'weaviate',
'--operation',
'resolve',
'--vector',
'0.1,0.2',
'--limit',
'2',
]).vector,
[0.1, 0.2],
);
assert.throws(
() => parseMemoryV2ExternalSmokeArgs(['--backend', 'unknown']),
/--backend must be one of/,
);
});
test('runMemoryV2ExternalSmokeCli smokes Qdrant through runtime HTTP wiring', async () => {
const stdout = writableBuffer();
const report = await runMemoryV2ExternalSmokeCli({
argv: ['--backend', 'qdrant', '--operation', 'resolve', '--limit', '2'],
env: {
MEMORY_QDRANT_ENABLED: '1',
MEMORY_QDRANT_URL: 'http://qdrant.local',
MEMORY_QDRANT_COLLECTION: 'memind_memory',
MEMORY_QDRANT_EMBEDDING_MODULE: './embed.mjs',
},
stdout,
async importModule() {
return { async embedQuery() { return [0.1, 0.2, 0.3]; } };
},
async fetchImpl(url, options) {
assert.equal(url, 'http://qdrant.local/collections/memind_memory/points/search');
assert.equal(JSON.parse(options.body).limit, 2);
return {
ok: true,
async json() {
return {
result: [
{
id: 'q1',
score: 0.9,
payload: { content: 'semantic memory', type: 'semantic' },
},
],
};
},
};
},
});
const output = JSON.parse(stdout.value());
assert.equal(report.backendAvailable, true);
assert.equal(output.selectedBackend, 'qdrant');
assert.equal(output.result.source, 'qdrant');
});
test('runMemoryV2ExternalSmokeCli smokes Weaviate through runtime HTTP wiring', async () => {
const stdout = writableBuffer();
const report = await runMemoryV2ExternalSmokeCli({
argv: ['--backend', 'weaviate', '--operation', 'resolve', '--limit', '2'],
env: {
MEMORY_WEAVIATE_ENABLED: '1',
MEMORY_WEAVIATE_URL: 'https://weaviate.local',
MEMORY_WEAVIATE_EMBEDDING_MODULE: './embed.mjs',
},
stdout,
async importModule() {
return { async embedQuery() { return [0.1, 0.2]; } };
},
async fetchImpl(url, options) {
assert.equal(url, 'https://weaviate.local/v1/graphql');
assert.equal(JSON.parse(options.body).variables.limit, 2);
return {
ok: true,
async json() {
return {
data: {
Get: {
MemindMemory: [{ content: 'semantic memory', _additional: { id: 'w1' } }],
},
},
};
},
};
},
});
const output = JSON.parse(stdout.value());
assert.equal(report.backendAvailable, true);
assert.equal(output.selectedBackend, 'weaviate');
assert.equal(output.result.source, 'weaviate');
});
test('runMemoryV2ExternalSmokeCli smokes Redis Streams with injected redis module', async () => {
const stdout = writableBuffer();
const xAdds = [];
let quitCalled = false;
const report = await runMemoryV2ExternalSmokeCli({
argv: ['--backend', 'redis-streams'],
env: {
MEMORY_REDIS_STREAMS_ENABLED: '1',
MEMORY_REDIS_STREAMS_URL: 'redis://localhost:6379',
},
stdout,
async importRedis() {
return {
createClient() {
return {
async connect() {},
async xAdd(stream, id, fields) {
xAdds.push({ stream, id, fields });
},
async quit() {
quitCalled = true;
},
};
},
};
},
});
assert.equal(report.backendAvailable, true);
assert.equal(report.result.source, 'redis-streams');
assert.equal(xAdds[0].stream, 'memind:memory-events');
assert.equal(quitCalled, true);
});
test('runMemoryV2ExternalSmokeCli reports fallback when backend is not configured', async () => {
const stdout = writableBuffer();
const report = await runMemoryV2ExternalSmokeCli({
argv: ['--backend', 'langgraph'],
env: {},
stdout,
});
assert.equal(report.backendAvailable, false);
assert.equal(report.selectedBackend, 'legacy-conversation-memory');
assert.equal(report.backendReason, 'not_configured');
});
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
import { runPgvectorMemorySmoke } from '../memory-v2-pgvector-smoke.mjs';
const DEFAULT_URL_ENV = 'MEMORY_PGVECTOR_DATABASE_URL';
function usage() {
return `
Usage:
node scripts/smoke-memory-v2-pgvector.mjs [options]
Runs a local pgvector read/write smoke test with synthetic rows.
Options:
--table <name> Table name. Default: memory_embeddings.
--dimensions <number> Embedding dimensions. Default: 3.
--create-schema Ensure schema before smoke. Default: false.
--create-extension Include CREATE EXTENSION IF NOT EXISTS vector when creating schema.
--keep-rows Do not delete synthetic smoke rows.
--url-env <name> Env var containing PostgreSQL URL. Default: ${DEFAULT_URL_ENV}.
-h, --help Show this help.
`.trim();
}
export function parseMemoryPgvectorSmokeArgs(argv = []) {
const options = {
tableName: 'memory_embeddings',
dimensions: 3,
createSchema: false,
createExtension: false,
cleanup: true,
urlEnv: DEFAULT_URL_ENV,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
switch (arg) {
case '--table':
i += 1;
options.tableName = argv[i];
break;
case '--dimensions':
i += 1;
options.dimensions = Number(argv[i]);
break;
case '--create-schema':
options.createSchema = true;
break;
case '--create-extension':
options.createExtension = true;
break;
case '--keep-rows':
options.cleanup = false;
break;
case '--url-env':
i += 1;
options.urlEnv = argv[i];
break;
case '-h':
case '--help':
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.tableName) throw new Error('--table requires a value');
if (!Number.isInteger(options.dimensions) || options.dimensions < 1) {
throw new Error('--dimensions requires a positive integer');
}
if (!options.urlEnv) throw new Error('--url-env requires a value');
return options;
}
export async function runMemoryPgvectorSmokeCli(
argv = process.argv.slice(2),
env = process.env,
{ importPg = (specifier) => import(specifier), stdout = process.stdout } = {},
) {
const options = parseMemoryPgvectorSmokeArgs(argv);
if (options.help) {
stdout.write(`${usage()}\n`);
return { ok: true, mode: 'help' };
}
const connectionString = env[options.urlEnv];
if (!connectionString) {
throw new Error(`pgvector smoke requires ${options.urlEnv} to be set`);
}
const imported = await importPg('pg');
const PgPool = imported?.Pool ?? imported?.default?.Pool;
if (typeof PgPool !== 'function') throw new Error('pg module does not export Pool');
const pool = new PgPool({ connectionString, max: 1 });
try {
const result = await runPgvectorMemorySmoke({
pool,
tableName: options.tableName,
dimensions: options.dimensions,
createSchema: options.createSchema,
createExtension: options.createExtension,
cleanup: options.cleanup,
});
stdout.write(`${JSON.stringify({
...result,
checkedAt: new Date().toISOString(),
}, null, 2)}\n`);
return result;
} finally {
await pool.end();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryPgvectorSmokeCli().then((result) => {
process.exitCode = result.ok ? 0 : 1;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseMemoryPgvectorSmokeArgs,
runMemoryPgvectorSmokeCli,
} from './smoke-memory-v2-pgvector.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
test('parseMemoryPgvectorSmokeArgs defaults to safe existing-schema smoke', () => {
assert.deepEqual(parseMemoryPgvectorSmokeArgs([]), {
tableName: 'memory_embeddings',
dimensions: 3,
createSchema: false,
createExtension: false,
cleanup: true,
urlEnv: 'MEMORY_PGVECTOR_DATABASE_URL',
help: false,
});
});
test('parseMemoryPgvectorSmokeArgs maps local setup options', () => {
assert.deepEqual(
parseMemoryPgvectorSmokeArgs([
'--table',
'memory_smoke',
'--dimensions',
'4',
'--create-schema',
'--create-extension',
'--keep-rows',
'--url-env',
'TEST_PG_URL',
]),
{
tableName: 'memory_smoke',
dimensions: 4,
createSchema: true,
createExtension: true,
cleanup: false,
urlEnv: 'TEST_PG_URL',
help: false,
},
);
});
test('runMemoryPgvectorSmokeCli requires explicit pgvector URL', async () => {
await assert.rejects(
() => runMemoryPgvectorSmokeCli([], {}, { stdout: writableBuffer() }),
/MEMORY_PGVECTOR_DATABASE_URL/,
);
});
test('runMemoryPgvectorSmokeCli opens pool, runs smoke, and closes pool', async () => {
const stdout = writableBuffer();
let poolEnded = false;
class FakePool {
constructor(options) {
this.options = options;
}
async query(sql) {
if (/SELECT id, content, type, created_at/.test(sql)) {
return {
rows: [{
id: 1,
content: 'memory-v2 smoke near vector',
type: 'smoke',
score: 1,
}],
};
}
return { rows: [] };
}
async end() {
poolEnded = true;
}
}
const result = await runMemoryPgvectorSmokeCli(
['--create-schema', '--create-extension'],
{ MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory' },
{
stdout,
async importPg(specifier) {
assert.equal(specifier, 'pg');
return { Pool: FakePool };
},
},
);
const output = JSON.parse(stdout.value());
assert.equal(result.ok, true);
assert.equal(output.ok, true);
assert.equal(output.expectedTopText, 'memory-v2 smoke near vector');
assert.equal(poolEnded, true);
});
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env node
import { createQdrantHttpClient } from '../memory-v2-qdrant.mjs';
const DEFAULT_URL_ENV = 'MEMORY_QDRANT_URL';
function usage() {
return `
Usage:
node scripts/smoke-memory-v2-qdrant.mjs [options]
Runs a read-only Qdrant search smoke test. It does not create collections or write points.
Options:
--collection <name> Collection name. Default: MEMORY_QDRANT_COLLECTION or memind_memory.
--vector <csv> Query vector CSV. Default: 1,0,0.
--limit <number> Result limit. Default: 1.
--url-env <name> Env var containing Qdrant URL. Default: ${DEFAULT_URL_ENV}.
--api-key-env <name> Env var containing Qdrant API key. Default: MEMORY_QDRANT_API_KEY.
--timeout-ms <number> Request timeout. Default: 3000.
-h, --help Show this help.
`.trim();
}
function parseVector(value) {
const vector = String(value ?? '1,0,0')
.split(',')
.map((item) => Number(item.trim()));
if (!vector.length || vector.some((item) => !Number.isFinite(item))) {
throw new Error('--vector must be a comma-separated list of numbers');
}
return vector;
}
export function parseMemoryQdrantSmokeArgs(argv = [], env = process.env) {
const options = {
collection: env.MEMORY_QDRANT_COLLECTION || 'memind_memory',
vector: [1, 0, 0],
limit: 1,
urlEnv: DEFAULT_URL_ENV,
apiKeyEnv: 'MEMORY_QDRANT_API_KEY',
timeoutMs: 3000,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
switch (arg) {
case '--collection':
options.collection = argv[++i] ?? '';
break;
case '--vector':
options.vector = parseVector(argv[++i]);
break;
case '--limit':
options.limit = Number(argv[++i]);
break;
case '--url-env':
options.urlEnv = argv[++i] ?? '';
break;
case '--api-key-env':
options.apiKeyEnv = argv[++i] ?? '';
break;
case '--timeout-ms':
options.timeoutMs = Number(argv[++i]);
break;
case '-h':
case '--help':
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.collection) throw new Error('--collection requires a value');
if (!Number.isInteger(options.limit) || options.limit < 1) {
throw new Error('--limit requires a positive integer');
}
if (!options.urlEnv) throw new Error('--url-env requires a value');
if (!options.apiKeyEnv) throw new Error('--api-key-env requires a value');
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1) {
throw new Error('--timeout-ms requires a positive number');
}
return options;
}
export async function runMemoryQdrantSmokeCli({
argv = process.argv.slice(2),
env = process.env,
stdout = process.stdout,
fetchImpl = globalThis.fetch,
} = {}) {
const options = parseMemoryQdrantSmokeArgs(argv, env);
if (options.help) {
stdout.write(`${usage()}\n`);
return { ok: true, mode: 'help' };
}
const url = env[options.urlEnv];
if (!url) throw new Error(`Qdrant smoke requires ${options.urlEnv} to be set`);
const client = createQdrantHttpClient({
url,
apiKey: env[options.apiKeyEnv],
fetchImpl,
timeoutMs: options.timeoutMs,
});
const points = await client.search({
collection: options.collection,
vector: options.vector,
limit: options.limit,
});
const report = {
ok: true,
checkedAt: new Date().toISOString(),
collection: options.collection,
resultCount: points.length,
readOnly: true,
};
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return report;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryQdrantSmokeCli().then((result) => {
process.exitCode = result.ok ? 0 : 1;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseMemoryQdrantSmokeArgs,
runMemoryQdrantSmokeCli,
} from './smoke-memory-v2-qdrant.mjs';
function writableBuffer() {
let value = '';
return {
write(chunk) {
value += String(chunk);
},
value() {
return value;
},
};
}
test('parseMemoryQdrantSmokeArgs defaults to read-only smoke settings', () => {
assert.deepEqual(parseMemoryQdrantSmokeArgs([], {}), {
collection: 'memind_memory',
vector: [1, 0, 0],
limit: 1,
urlEnv: 'MEMORY_QDRANT_URL',
apiKeyEnv: 'MEMORY_QDRANT_API_KEY',
timeoutMs: 3000,
help: false,
});
});
test('parseMemoryQdrantSmokeArgs maps options', () => {
assert.deepEqual(
parseMemoryQdrantSmokeArgs([
'--collection',
'memind_memory_canary',
'--vector',
'0.1,0.2,0.3',
'--limit',
'3',
'--url-env',
'QDRANT_URL',
'--api-key-env',
'QDRANT_KEY',
'--timeout-ms',
'500',
], {}),
{
collection: 'memind_memory_canary',
vector: [0.1, 0.2, 0.3],
limit: 3,
urlEnv: 'QDRANT_URL',
apiKeyEnv: 'QDRANT_KEY',
timeoutMs: 500,
help: false,
},
);
});
test('runMemoryQdrantSmokeCli requires explicit Qdrant URL', async () => {
await assert.rejects(
() => runMemoryQdrantSmokeCli({ env: {}, stdout: writableBuffer() }),
/MEMORY_QDRANT_URL/,
);
});
test('runMemoryQdrantSmokeCli performs read-only search', async () => {
const stdout = writableBuffer();
const calls = [];
const report = await runMemoryQdrantSmokeCli({
argv: ['--collection', 'memind_memory', '--vector', '1,0,0', '--limit', '2'],
env: {
MEMORY_QDRANT_URL: 'http://127.0.0.1:6333',
MEMORY_QDRANT_API_KEY: 'secret',
},
stdout,
async fetchImpl(url, options) {
calls.push({ url, options });
return {
ok: true,
async json() {
return { result: [{ id: 'p1' }, { id: 'p2' }] };
},
};
},
});
const output = JSON.parse(stdout.value());
assert.equal(report.ok, true);
assert.equal(output.readOnly, true);
assert.equal(output.resultCount, 2);
assert.equal(calls.length, 1);
assert.equal(JSON.parse(calls[0].options.body).with_payload, true);
});