Merge Memory V2 runtime facade
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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())));
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user