diff --git a/packages/memory/package.json b/packages/memory/package.json index 392c8eb..fad7d51 100644 --- a/packages/memory/package.json +++ b/packages/memory/package.json @@ -25,6 +25,7 @@ "@mosaicstack/db": "workspace:*", "@mosaicstack/storage": "workspace:*", "@mosaicstack/types": "workspace:*", + "commander": "^13.0.0", "drizzle-orm": "^0.45.1" }, "devDependencies": { diff --git a/packages/memory/src/cli.spec.ts b/packages/memory/src/cli.spec.ts new file mode 100644 index 0000000..a293d1d --- /dev/null +++ b/packages/memory/src/cli.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { registerMemoryCommand } from './cli.js'; + +/** + * Smoke test — only verifies command wiring. + * Does NOT open a database connection. + */ +describe('registerMemoryCommand', () => { + function buildProgram(): Command { + const program = new Command('mosaic'); + program.exitOverride(); // prevent process.exit during tests + registerMemoryCommand(program); + return program; + } + + it('registers a "memory" subcommand', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory'); + expect(memory).toBeDefined(); + }); + + it('registers "memory search"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const search = memory.commands.find((c) => c.name() === 'search'); + expect(search).toBeDefined(); + }); + + it('registers "memory stats"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const stats = memory.commands.find((c) => c.name() === 'stats'); + expect(stats).toBeDefined(); + }); + + it('registers "memory insights list"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const insights = memory.commands.find((c) => c.name() === 'insights'); + expect(insights).toBeDefined(); + const list = insights!.commands.find((c) => c.name() === 'list'); + expect(list).toBeDefined(); + }); + + it('registers "memory preferences list"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const preferences = memory.commands.find((c) => c.name() === 'preferences'); + expect(preferences).toBeDefined(); + const list = preferences!.commands.find((c) => c.name() === 'list'); + expect(list).toBeDefined(); + }); + + it('"memory search" has --limit and --agent options', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const search = memory.commands.find((c) => c.name() === 'search')!; + const optNames = search.options.map((o) => o.long); + expect(optNames).toContain('--limit'); + expect(optNames).toContain('--agent'); + }); +}); diff --git a/packages/memory/src/cli.ts b/packages/memory/src/cli.ts new file mode 100644 index 0000000..f3cb216 --- /dev/null +++ b/packages/memory/src/cli.ts @@ -0,0 +1,179 @@ +import type { Command } from 'commander'; + +import type { MemoryAdapter } from './types.js'; + +/** + * Build and return a connected MemoryAdapter from a connection string or + * the MEMORY_DB_URL / DATABASE_URL environment variable. + * + * For pgvector (postgres://...) the connection string is injected into + * DATABASE_URL so that PgVectorAdapter's internal createDb() picks it up. + * + * Throws with a human-readable message if no connection info is available. + */ +async function resolveAdapter(dbOption: string | undefined): Promise { + const connStr = dbOption ?? process.env['MEMORY_DB_URL'] ?? process.env['DATABASE_URL']; + if (!connStr) { + throw new Error( + 'No database connection string provided. ' + + 'Pass --db or set MEMORY_DB_URL / DATABASE_URL.', + ); + } + + // Lazy imports so the module loads cleanly without a live DB during smoke tests. + const { createMemoryAdapter, registerMemoryAdapter } = await import('./factory.js'); + + if (connStr.startsWith('postgres') || connStr.startsWith('pg')) { + // PgVectorAdapter reads DATABASE_URL via createDb() — inject it here. + process.env['DATABASE_URL'] = connStr; + + const { PgVectorAdapter } = await import('./adapters/pgvector.js'); + registerMemoryAdapter('pgvector', (cfg) => new PgVectorAdapter(cfg as never)); + return createMemoryAdapter({ type: 'pgvector' }); + } + + // Keyword adapter backed by pglite storage; treat connStr as a data directory. + const { KeywordAdapter } = await import('./adapters/keyword.js'); + const { createStorageAdapter, registerStorageAdapter } = await import('@mosaicstack/storage'); + const { PgliteAdapter } = await import('@mosaicstack/storage'); + + registerStorageAdapter('pglite', (cfg) => new PgliteAdapter(cfg as never)); + + const storage = createStorageAdapter({ type: 'pglite', dataDir: connStr }); + + registerMemoryAdapter('keyword', (cfg) => new KeywordAdapter(cfg as never)); + return createMemoryAdapter({ type: 'keyword', storage }); +} + +/** + * Register `memory` subcommands on an existing Commander program. + * Follows the registerQualityRails pattern from @mosaicstack/quality-rails. + */ +export function registerMemoryCommand(parent: Command): void { + const memory = parent.command('memory').description('Inspect and query the Mosaic memory layer'); + + // ── memory search ────────────────────────────────────────────── + memory + .command('search ') + .description('Semantic search over insights') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--limit ', 'Maximum number of results', '10') + .option('--agent ', 'Filter by agent / user ID') + .action(async (query: string, opts: { db?: string; limit: string; agent?: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + const limit = parseInt(opts.limit, 10); + const userId = opts.agent ?? 'system'; + const results = await adapter.searchInsights(userId, query, { limit }); + + if (results.length === 0) { + console.log('No insights found.'); + } else { + for (const r of results) { + console.log(`[${r.id}] (score=${r.score.toFixed(3)}) ${r.content}`); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); + + // ── memory stats ────────────────────────────────────────────────────── + memory + .command('stats') + .description('Print memory tier info: adapter type, insight count, preference count') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--agent ', 'User / agent ID scope for counts', 'system') + .action(async (opts: { db?: string; agent: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + + const adapterType = adapter.name; + + const insightCount = await adapter + .searchInsights(opts.agent, '', { limit: 100000 }) + .then((r) => r.length) + .catch(() => -1); + + const prefCount = await adapter + .listPreferences(opts.agent) + .then((r) => r.length) + .catch(() => -1); + + console.log(`adapter: ${adapterType}`); + console.log(`insights: ${insightCount === -1 ? 'unavailable' : String(insightCount)}`); + console.log(`preferences: ${prefCount === -1 ? 'unavailable' : String(prefCount)}`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); + + // ── memory insights ─────────────────────────────────────────────────── + const insightsCmd = memory.command('insights').description('Manage insights'); + + insightsCmd + .command('list') + .description('List recent insights') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--limit ', 'Maximum number of results', '20') + .option('--agent ', 'User / agent ID scope', 'system') + .action(async (opts: { db?: string; limit: string; agent: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + const limit = parseInt(opts.limit, 10); + const results = await adapter.searchInsights(opts.agent, '', { limit }); + + if (results.length === 0) { + console.log('No insights found.'); + } else { + for (const r of results) { + console.log(`[${r.id}] ${r.content}`); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); + + // ── memory preferences ──────────────────────────────────────────────── + const prefsCmd = memory.command('preferences').description('Manage stored preferences'); + + prefsCmd + .command('list') + .description('List stored preferences') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--agent ', 'User / agent ID scope', 'system') + .option('--category ', 'Filter by category') + .action(async (opts: { db?: string; agent: string; category?: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + const prefs = await adapter.listPreferences(opts.agent, opts.category); + + if (prefs.length === 0) { + console.log('No preferences found.'); + } else { + for (const p of prefs) { + console.log(`[${p.category}] ${p.key} = ${JSON.stringify(p.value)}`); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); +} diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index bcbd5dd..97f734d 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -1,4 +1,5 @@ export { createMemory, type Memory } from './memory.js'; +export { registerMemoryCommand } from './cli.js'; export { createPreferencesRepo, type PreferencesRepo, diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index f51eda3..4fdb0e3 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -31,6 +31,7 @@ "@mosaicstack/config": "workspace:*", "@mosaicstack/forge": "workspace:*", "@mosaicstack/macp": "workspace:*", + "@mosaicstack/memory": "workspace:*", "@mosaicstack/prdy": "workspace:*", "@mosaicstack/quality-rails": "workspace:*", "@mosaicstack/queue": "workspace:*", diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index f64570a..5a79f5c 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -3,6 +3,7 @@ import { createRequire } from 'module'; import { Command } from 'commander'; import { registerBrainCommand } from '@mosaicstack/brain'; +import { registerMemoryCommand } from '@mosaicstack/memory'; import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQueueCommand } from '@mosaicstack/queue'; import { registerAgentCommand } from './commands/agent.js'; @@ -348,6 +349,10 @@ registerBrainCommand(program); registerQualityRails(program); +// ─── memory ────────────────────────────────────────────────────────────── + +registerMemoryCommand(program); + // ─── queue ─────────────────────────────────────────────────────────────── registerQueueCommand(program); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 606620d..ceb409f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -441,6 +441,9 @@ importers: '@mosaicstack/types': specifier: workspace:* version: link:../types + commander: + specifier: ^13.0.0 + version: 13.1.0 drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8) @@ -469,6 +472,9 @@ importers: '@mosaicstack/macp': specifier: workspace:* version: link:../macp + '@mosaicstack/memory': + specifier: workspace:* + version: link:../memory '@mosaicstack/prdy': specifier: workspace:* version: link:../prdy