Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ services:
LONGMEMORY_RATE_LIMIT_MAX_REQUESTS: ${LONGMEMORY_RATE_LIMIT_MAX_REQUESTS:-100}
LONGMEMORY_EMBEDDING_PROVIDER: ${LONGMEMORY_EMBEDDING_PROVIDER:-}
LONGMEMORY_EMBEDDING_TIER: ${LONGMEMORY_EMBEDDING_TIER:-deep}
LONGMEMORY_EMBEDDING_FALLBACK: ${LONGMEMORY_EMBEDDING_FALLBACK:-synthetic}
LONGMEMORY_EMBEDDING_DIMENSION: ${LONGMEMORY_EMBEDDING_DIMENSION:-1536}
LONGMEMORY_EMBEDDING_TIMEOUT_MS: ${LONGMEMORY_EMBEDDING_TIMEOUT_MS:-30000}
LONGMEMORY_EMBEDDING_MAX_RETRIES: ${LONGMEMORY_EMBEDDING_MAX_RETRIES:-2}
LONGMEMORY_EMBEDDING_RETRY_BASE_MS: ${LONGMEMORY_EMBEDDING_RETRY_BASE_MS:-250}
LONGMEMORY_OPENAI_BASE_URL: ${LONGMEMORY_OPENAI_BASE_URL:-https://api.openai.com/v1}
LONGMEMORY_OPENAI_EMBEDDING_MODEL: ${LONGMEMORY_OPENAI_EMBEDDING_MODEL:-text-embedding-3-small}
LONGMEMORY_OLLAMA_URL: ${LONGMEMORY_OLLAMA_URL:-http://host.docker.internal:11434}
LONGMEMORY_OLLAMA_EMBEDDING_MODEL: ${LONGMEMORY_OLLAMA_EMBEDDING_MODEL:-nomic-embed-text}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
Expand Down
4 changes: 3 additions & 1 deletion src/cli/cli_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const commands = new Map<string, cli_command_loader>([
['doctor', async () => (await import('./commands/doctor.js')).doctor_command],
['serve', async () => (await import('./commands/serve.js')).serve_command],
['mcp', async () => (await import('./commands/mcp.js')).mcp_command],
['migrate', async () => (await import('./commands/migrate.js')).migrate_command],
['ingest', async () => (await import('./commands/ingest.js')).ingest_command],
['recall', async () => (await import('./commands/recall.js')).recall_command],
['explain', async () => (await import('./commands/explain.js')).explain_command],
Expand Down Expand Up @@ -82,6 +83,7 @@ const help = {
global_flags: ['--db <path>', '--project <id>', '--user <id>', '--json', '--jsonl', '--pretty', '--compact', '--no-color', '--silent', '--interactive', '--dry-run', '--token-budget <number>', '--cwd <path>'],
commands: [
'status', 'init', 'doctor', 'serve [--host <host>] [--port <port>] [--mcp-http]', 'mcp [--read-only]',
'migrate --from <legacy.db> --to <longmemory.db> [--report <report.json>]',
'ingest "memory" [--stdin] [--type <type>] [--source <source>]', 'recall "query" [--mode <mode>]', 'explain <memory-id>',
'timeline <entity|project|memory>', 'memory list [--limit <n>] [--status <status>]',
'maintenance decay [--limit <n>] [--all]', 'maintenance reinforce <memory-id>',
Expand Down Expand Up @@ -135,4 +137,4 @@ export async function run_cli_app(argv = process.argv.slice(2), env: NodeJS.Proc
}

export const registered_commands = () => [...commands.keys()];
export const register_cli_command = (name: string, command: cli_command) => commands.set(name, async () => command);
export const register_cli_command = (name: string, command: cli_command) => commands.set(name, async () => command);
43 changes: 43 additions & 0 deletions src/cli/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* __ __ ___
* / / ____ ____ ____ _/ |/ /__ ____ ___ ____ _______ __
* / / / __ \/ __ \/ __ `/ /|_/ / _ \/ __ `__ \/ __ \/ ___/ / / /
* / /___/ /_/ / / / / /_/ / / / / __/ / / / / / /_/ / / / /_/ /
* /_____/\____/_/ /_/\__, /_/ /_/\___/_/ /_/ /_/\____/_/ \__, /
/____/ /____/
*
* cavira oss (c) 2026 - nullure (c) 2026
* ----------------------------------------------------------
* file : src/cli/commands/migrate.ts
* usage : implements the LongMemory legacy migration command
*/


import type { memory_config as longmemory_config } from '../../core/create_memory.js';
import { migrate_legacy } from '../../core/migration/legacy_mapper.js';
import { write_migration_report } from '../../core/migration/migration_report.js';
import type { cli_command } from '../context/cli_context.js';
import { command_flags, flag, memory_config, require_value } from '../context/cli_context.js';
import { emit } from '../output/pretty.js';
import { panel } from '../output/panel.js';

export const migrate_command: cli_command = async (context) => {
command_flags(context, ['from', 'to', 'report']);
const from = require_value(flag(context, 'from'), '--from');
const to = require_value(flag(context, 'to'), '--to');
const configured = memory_config(context);
const migration_config: Omit<longmemory_config, 'store' | 'db_path' | 'readonly'> = {};
if (configured.embedding_provider) migration_config.embedding_provider = configured.embedding_provider;
if (configured.embedding_dimension !== undefined) migration_config.embedding_dimension = configured.embedding_dimension;
if (configured.multilingual_embedding_provider) migration_config.multilingual_embedding_provider = configured.multilingual_embedding_provider;
const report = await migrate_legacy({ from, to, memory_config: migration_config });
const report_path = write_migration_report(report, flag(context, 'report') ?? `${to}.migration-report.json`);
const result = { ok: report.benchmark_result.passed && report.errors.length === 0, report_path, ...report };
emit(context, result, () => panel('', context.colors, {
title: 'Legacy migration', kind: result.ok ? 'success' : 'warning', width: context.terminal_width, rows: [
['Source', report.source_path], ['Destination', report.destination_path], ['Imported nodes', report.imported_nodes],
['Imported edges', report.imported_edges], ['Skipped records', report.skipped_records.length],
['Errors', report.errors.length], ['Integrity benchmark', report.benchmark_result.passed], ['Report', report_path],
],
}));
};
8 changes: 6 additions & 2 deletions src/core/embeddings/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import { create_embedding_stack } from './stack.js';
const providers = new Set<embedding_provider_name>(['openai', 'gemini', 'aws', 'ollama', 'local', 'siray', 'synthetic']);
const tiers = new Set<embedding_tier>(['fast', 'smart', 'deep', 'hybrid']);
const value = (env: NodeJS.ProcessEnv, ...keys: string[]) => keys.map((key) => env[key]?.trim()).find(Boolean);
const explicit_value = (env: NodeJS.ProcessEnv, ...keys: string[]) => {
const key = keys.find((candidate) => Object.prototype.hasOwnProperty.call(env, candidate));
return key === undefined ? undefined : env[key]?.trim();
};
const number_value = (env: NodeJS.ProcessEnv, keys: string[], fallback: number, min = 0) => {
const raw = value(env, ...keys);
if (!raw) return fallback;
Expand All @@ -42,7 +46,7 @@ export function load_embedding_environment(env: NodeJS.ProcessEnv = process.env)
const default_dimension = tier === 'smart' ? 384 : tier === 'deep' ? 1536 : 256;
return {
provider: provider(selected, 'synthetic'),
fallback: (value(env, 'LONGMEMORY_EMBEDDING_FALLBACK', 'OM_EMBEDDING_FALLBACK') ?? 'synthetic').split(',').map((name) => provider(name.trim(), 'synthetic')),
fallback: (explicit_value(env, 'LONGMEMORY_EMBEDDING_FALLBACK', 'OM_EMBEDDING_FALLBACK') ?? 'synthetic').split(',').map((name) => name.trim()).filter(Boolean).map((name) => provider(name, 'synthetic')),
tier,
dimension: number_value(env, ['LONGMEMORY_EMBEDDING_DIMENSION', 'OM_VEC_DIM', 'OM_MAX_VECTOR_DIM'], default_dimension, 1),
timeout_ms: number_value(env, ['LONGMEMORY_EMBEDDING_TIMEOUT_MS', 'OM_EMBED_TIMEOUT_MS'], 30_000, 1),
Expand Down Expand Up @@ -75,4 +79,4 @@ export function create_embedding_environment(env: NodeJS.ProcessEnv = process.en
embed: (text: string, language?: string) => embedding_provider.embed(text, { language, purpose: 'document' }),
};
return { config, embedding_provider, multilingual_embedding_provider, embedding_dimension: config.dimension };
}
}
41 changes: 29 additions & 12 deletions src/core/migration/legacy_mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import Database from 'better-sqlite3';
import { create_memory } from '../create_memory.js';
import { create_memory, type memory_config } from '../create_memory.js';
import { create_hydro_edge } from '../memory/durable_graph.js';
import { project_memory } from '../project/project_memory.js';
import { manual_provenance } from '../types/provenance.js';
import { SqliteStore } from '../../stores/sqlite/sqlite_store.js';
import { clean_legacy_data } from './legacy_cleaner.js';
Expand All @@ -28,6 +29,7 @@ export type legacy_migration_options = {
from: string;
to: string;
overwrite?: boolean;
memory_config?: Omit<memory_config, 'store' | 'db_path' | 'readonly'>;
};

const supported_relations = new Set(['contains', 'refers_to', 'same_as', 'supports', 'contradicts', 'supersedes', 'derived_from', 'grounds', 'semantic_shift']);
Expand All @@ -48,11 +50,15 @@ const relation_type = (value: string) => {
return supported_relations.has(mapped) ? mapped : 'refers_to';
};

async function benchmark_migration(path: string, imported_node_ids: string[]): Promise<migration_benchmark_result> {
async function benchmark_migration(
path: string,
imported_node_ids: string[],
memory_config?: Omit<memory_config, 'store' | 'db_path' | 'readonly'>,
): Promise<migration_benchmark_result> {
const store = new SqliteStore(path, { startup_integrity_check: true });
const integrity = store.check_integrity();
store.close();
const memory = create_memory({ store: 'sqlite', db_path: path });
const memory = create_memory({ ...memory_config, store: 'sqlite', db_path: path, readonly: true });
try {
const stats = await memory.getStats();
const hydration = imported_node_ids.length === 0 || (await memory.explain(imported_node_ids[0])).node !== null;
Expand All @@ -67,10 +73,15 @@ async function benchmark_migration(path: string, imported_node_ids: string[]): P
}
}

async function copy_hydrograph(from: string, to: string, started_at: number): Promise<migration_report> {
async function copy_hydrograph(
from: string,
to: string,
started_at: number,
memory_config?: Omit<memory_config, 'store' | 'db_path' | 'readonly'>,
): Promise<migration_report> {
const source = new Database(from, { readonly: true, fileMustExist: true });
try { await source.backup(to); } finally { source.close(); }
const memory = create_memory({ store: 'sqlite', db_path: to });
const memory = create_memory({ ...memory_config, store: 'sqlite', db_path: to, readonly: true });
const stats = await memory.getStats();
await memory.close();
const store = new SqliteStore(to);
Expand All @@ -97,7 +108,7 @@ async function copy_hydrograph(from: string, to: string, started_at: number): Pr
contradictions_found: 0,
skipped_records: [],
errors: [],
benchmark_result: await benchmark_migration(to, node_ids),
benchmark_result: await benchmark_migration(to, node_ids, memory_config),
};
}

Expand All @@ -110,7 +121,7 @@ export async function migrate_legacy(options: legacy_migration_options): Promise
if (existsSync(to) && !options.overwrite) throw new Error(`migration destination already exists: ${to}`);
mkdirSync(dirname(to), { recursive: true });
const read = read_legacy_source(from);
if (read.format === 'hydrograph') return copy_hydrograph(from, to, started_at);
if (read.format === 'hydrograph') return copy_hydrograph(from, to, started_at, options.memory_config);
const clean = clean_legacy_data(read);
const imported_node_ids = new Set<string>();
const imported_edge_ids = new Set<string>();
Expand All @@ -120,8 +131,14 @@ export async function migrate_legacy(options: legacy_migration_options): Promise
const skipped = [...clean.skipped];
const errors = [...clean.errors];
let contradictions_found = 0;
const memory = create_memory({ store: 'sqlite', db_path: to, enable_consolidation: true });
const memory = create_memory({ ...options.memory_config, store: 'sqlite', db_path: to, enable_consolidation: true });
try {
const projects = new project_memory({ memory, tenant_id: 'default', project_id: 'legacy-migration', name: 'Legacy migration' });
const document_worlds = new Map<string, string>();
for (const project_id of new Set(clean.records.map((item) => item.world))) {
const project = await projects.createProject({ tenant_id: 'default', project_id, name: project_id });
document_worlds.set(project_id, project.world_ids.documents);
}
for (const item of clean.records) {
try {
const result = await memory.ingest({
Expand All @@ -132,13 +149,13 @@ export async function migrate_legacy(options: legacy_migration_options): Promise
observed_at: item.observed_at,
valid_from: item.valid_from,
valid_to: item.valid_to,
world: item.world,
world_id: document_worlds.get(item.world),
tags: item.tags,
facet_hint: item.facet,
external: item.source !== null,
source: item.source ?? undefined,
contract: item.source ? undefined : { requires_grounding: false, source_required: false },
metadata: item.metadata,
metadata: { ...item.metadata, project_id: item.world, canonical_project: item.world },
});
node_by_source.set(item.source_id, result.node.id);
result.diff.created_node_ids.forEach((id) => imported_node_ids.add(id));
Expand Down Expand Up @@ -208,7 +225,7 @@ export async function migrate_legacy(options: legacy_migration_options): Promise
contradictions_found,
skipped_records: skipped,
errors,
benchmark_result: await benchmark_migration(to, [...imported_node_ids]),
benchmark_result: await benchmark_migration(to, [...imported_node_ids], options.memory_config),
};
return report;
}
}
Loading