Compare commits

...

2 Commits

Author SHA1 Message Date
Jarvis
ad0255cde6 feat(memory): add registerMemoryCommand for mosaic memory CLI surface
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Adds mosaic memory CLI with four subcommands: search, stats,
insights list, and preferences list. Commands accept --db flag
(or MEMORY_DB_URL/DATABASE_URL env) to connect to pgvector or
keyword adapter. Smoke tests verify command wiring without DB.

Task: CU-05-04

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:34:03 -05:00
5425f9268e feat(queue): mosaic queue CLI surface (#404)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 05:27:59 +00:00
11 changed files with 580 additions and 0 deletions

View File

@@ -25,6 +25,7 @@
"@mosaicstack/db": "workspace:*", "@mosaicstack/db": "workspace:*",
"@mosaicstack/storage": "workspace:*", "@mosaicstack/storage": "workspace:*",
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*",
"commander": "^13.0.0",
"drizzle-orm": "^0.45.1" "drizzle-orm": "^0.45.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -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');
});
});

179
packages/memory/src/cli.ts Normal file
View File

@@ -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<MemoryAdapter> {
const connStr = dbOption ?? process.env['MEMORY_DB_URL'] ?? process.env['DATABASE_URL'];
if (!connStr) {
throw new Error(
'No database connection string provided. ' +
'Pass --db <connection-string> 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 <query> ──────────────────────────────────────────────
memory
.command('search <query>')
.description('Semantic search over insights')
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--limit <n>', 'Maximum number of results', '10')
.option('--agent <id>', '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 <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--agent <id>', '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 <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--limit <n>', 'Maximum number of results', '20')
.option('--agent <id>', '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 <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--agent <id>', 'User / agent ID scope', 'system')
.option('--category <cat>', '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();
}
});
}

View File

@@ -1,4 +1,5 @@
export { createMemory, type Memory } from './memory.js'; export { createMemory, type Memory } from './memory.js';
export { registerMemoryCommand } from './cli.js';
export { export {
createPreferencesRepo, createPreferencesRepo,
type PreferencesRepo, type PreferencesRepo,

View File

@@ -31,8 +31,10 @@
"@mosaicstack/config": "workspace:*", "@mosaicstack/config": "workspace:*",
"@mosaicstack/forge": "workspace:*", "@mosaicstack/forge": "workspace:*",
"@mosaicstack/macp": "workspace:*", "@mosaicstack/macp": "workspace:*",
"@mosaicstack/memory": "workspace:*",
"@mosaicstack/prdy": "workspace:*", "@mosaicstack/prdy": "workspace:*",
"@mosaicstack/quality-rails": "workspace:*", "@mosaicstack/quality-rails": "workspace:*",
"@mosaicstack/queue": "workspace:*",
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*",
"@clack/prompts": "^0.9.1", "@clack/prompts": "^0.9.1",
"commander": "^13.0.0", "commander": "^13.0.0",

View File

@@ -3,7 +3,9 @@
import { createRequire } from 'module'; import { createRequire } from 'module';
import { Command } from 'commander'; import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain'; import { registerBrainCommand } from '@mosaicstack/brain';
import { registerMemoryCommand } from '@mosaicstack/memory';
import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerQueueCommand } from '@mosaicstack/queue';
import { registerAgentCommand } from './commands/agent.js'; import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js'; import { registerMissionCommand } from './commands/mission.js';
// prdy is registered via launch.ts // prdy is registered via launch.ts
@@ -342,6 +344,14 @@ registerBrainCommand(program);
registerQualityRails(program); registerQualityRails(program);
// ─── memory ──────────────────────────────────────────────────────────────
registerMemoryCommand(program);
// ─── queue ───────────────────────────────────────────────────────────────
registerQueueCommand(program);
// ─── update ───────────────────────────────────────────────────────────── // ─── update ─────────────────────────────────────────────────────────────
program program

View File

@@ -22,6 +22,7 @@
}, },
"dependencies": { "dependencies": {
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*",
"commander": "^13.0.0",
"ioredis": "^5.10.0" "ioredis": "^5.10.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerQueueCommand } from './cli.js';
describe('registerQueueCommand', () => {
function buildProgram(): Command {
const program = new Command('mosaic');
registerQueueCommand(program);
return program;
}
it('registers a "queue" subcommand', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
expect(queueCmd).toBeDefined();
});
it('queue has list, stats, pause, resume, jobs, drain subcommands', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
expect(queueCmd).toBeDefined();
const names = queueCmd!.commands.map((c) => c.name());
expect(names).toContain('list');
expect(names).toContain('stats');
expect(names).toContain('pause');
expect(names).toContain('resume');
expect(names).toContain('jobs');
expect(names).toContain('drain');
});
it('jobs subcommand has a "tail" subcommand', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
const jobsCmd = queueCmd!.commands.find((c) => c.name() === 'jobs');
expect(jobsCmd).toBeDefined();
const tailCmd = jobsCmd!.commands.find((c) => c.name() === 'tail');
expect(tailCmd).toBeDefined();
});
it('drain has a --yes option', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
const drainCmd = queueCmd!.commands.find((c) => c.name() === 'drain');
expect(drainCmd).toBeDefined();
const optionNames = drainCmd!.options.map((o) => o.long);
expect(optionNames).toContain('--yes');
});
it('stats accepts an optional [name] argument', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
const statsCmd = queueCmd!.commands.find((c) => c.name() === 'stats');
expect(statsCmd).toBeDefined();
// Should not throw when called without argument
const args = statsCmd!.registeredArguments;
expect(args.length).toBe(1);
expect(args[0]!.required).toBe(false);
});
});

248
packages/queue/src/cli.ts Normal file
View File

@@ -0,0 +1,248 @@
import type { Command } from 'commander';
import { createLocalAdapter } from './adapters/local.js';
import type { QueueConfig } from './types.js';
/** Resolve adapter type from env; defaults to 'local'. */
function resolveAdapterType(): 'bullmq' | 'local' {
const t = process.env['QUEUE_ADAPTER'] ?? 'local';
return t === 'bullmq' ? 'bullmq' : 'local';
}
function resolveConfig(): QueueConfig {
const type = resolveAdapterType();
if (type === 'bullmq') {
return { type: 'bullmq', url: process.env['VALKEY_URL'] };
}
return { type: 'local', dataDir: process.env['QUEUE_DATA_DIR'] };
}
const BULLMQ_ONLY_MSG =
'not supported by local adapter — use the bullmq tier for this (set QUEUE_ADAPTER=bullmq)';
/**
* Register queue subcommands on an existing Commander program.
* Follows the same pattern as registerQualityRails in @mosaicstack/quality-rails.
*/
export function registerQueueCommand(parent: Command): void {
buildQueueCommand(parent.command('queue').description('Manage Mosaic job queues'));
}
function buildQueueCommand(queue: Command): void {
// ─── list ──────────────────────────────────────────────────────────────
queue
.command('list')
.description('List all queues known to the configured adapter')
.action(async () => {
const config = resolveConfig();
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
// Local adapter tracks queues in its internal Map; we expose them by
// listing JSON files in the data dir.
const { readdirSync } = await import('node:fs');
const { existsSync } = await import('node:fs');
const dataDir = config.dataDir ?? '.mosaic/queue';
if (!existsSync(dataDir)) {
console.log('No queues found (data dir does not exist yet).');
await adapter.close();
return;
}
const files = readdirSync(dataDir).filter((f: string) => f.endsWith('.json'));
if (files.length === 0) {
console.log('No queues found.');
} else {
console.log('Queues (local adapter):');
for (const f of files) {
console.log(` - ${f.slice(0, -5)}`);
}
}
await adapter.close();
return;
}
// bullmq — not enough info to enumerate queues without a BullMQ Board
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── stats ─────────────────────────────────────────────────────────────
queue
.command('stats [name]')
.description('Show stats for a queue (or all queues)')
.action(async (name?: string) => {
const config = resolveConfig();
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
const { readdirSync } = await import('node:fs');
const { existsSync } = await import('node:fs');
const dataDir = config.dataDir ?? '.mosaic/queue';
let names: string[] = [];
if (name) {
names = [name];
} else {
if (existsSync(dataDir)) {
names = readdirSync(dataDir)
.filter((f: string) => f.endsWith('.json'))
.map((f: string) => f.slice(0, -5));
}
}
if (names.length === 0) {
console.log('No queues found.');
await adapter.close();
return;
}
for (const queueName of names) {
const len = await adapter.length(queueName);
console.log(`Queue: ${queueName}`);
console.log(` waiting: ${len}`);
console.log(` active: 0 (local adapter — no active tracking)`);
console.log(` completed: 0 (local adapter — no completed tracking)`);
console.log(` failed: 0 (local adapter — no failed tracking)`);
console.log(` delayed: 0 (local adapter — no delayed tracking)`);
}
await adapter.close();
return;
}
// bullmq
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── pause ─────────────────────────────────────────────────────────────
queue
.command('pause <name>')
.description('Pause job processing for a queue')
.action(async (_name: string) => {
const config = resolveConfig();
if (config.type === 'local') {
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
return;
}
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── resume ────────────────────────────────────────────────────────────
queue
.command('resume <name>')
.description('Resume job processing for a queue')
.action(async (_name: string) => {
const config = resolveConfig();
if (config.type === 'local') {
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
return;
}
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── jobs tail ─────────────────────────────────────────────────────────
const jobs = queue.command('jobs').description('Job-level operations');
jobs
.command('tail [name]')
.description('Stream new jobs as they arrive (poll-based)')
.option('--interval <ms>', 'Poll interval in ms', '2000')
.action(async (name: string | undefined, opts: { interval: string }) => {
const config = resolveConfig();
const pollMs = parseInt(opts.interval, 10);
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
const { existsSync, readdirSync } = await import('node:fs');
const dataDir = config.dataDir ?? '.mosaic/queue';
let names: string[] = [];
if (name) {
names = [name];
} else {
if (existsSync(dataDir)) {
names = readdirSync(dataDir)
.filter((f: string) => f.endsWith('.json'))
.map((f: string) => f.slice(0, -5));
}
}
if (names.length === 0) {
console.log('No queues to tail.');
await adapter.close();
return;
}
console.log(`Tailing queues: ${names.join(', ')} (Ctrl-C to stop)`);
const lastLen = new Map<string, number>();
for (const qn of names) {
lastLen.set(qn, await adapter.length(qn));
}
const timer = setInterval(async () => {
for (const qn of names) {
const len = await adapter.length(qn);
const prev = lastLen.get(qn) ?? 0;
if (len > prev) {
console.log(
`[${new Date().toISOString()}] ${qn}: ${len - prev} new job(s) (total: ${len})`,
);
}
lastLen.set(qn, len);
}
}, pollMs);
process.on('SIGINT', async () => {
clearInterval(timer);
await adapter.close();
process.exit(0);
});
return;
}
// bullmq — use subscribe on the channel
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── drain ─────────────────────────────────────────────────────────────
queue
.command('drain <name>')
.description('Drain all pending jobs from a queue')
.option('--yes', 'Skip confirmation prompt')
.action(async (name: string, opts: { yes?: boolean }) => {
if (!opts.yes) {
console.error(
`WARNING: This will remove all pending jobs from queue "${name}". Re-run with --yes to confirm.`,
);
process.exit(1);
return;
}
const config = resolveConfig();
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
let removed = 0;
while ((await adapter.length(name)) > 0) {
await adapter.dequeue(name);
removed++;
}
console.log(`Drained ${removed} job(s) from queue "${name}".`);
await adapter.close();
return;
}
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
}

View File

@@ -11,6 +11,7 @@ export { type QueueAdapter, type QueueConfig as QueueAdapterConfig } from './typ
export { createQueueAdapter, registerQueueAdapter } from './factory.js'; export { createQueueAdapter, registerQueueAdapter } from './factory.js';
export { createBullMQAdapter } from './adapters/bullmq.js'; export { createBullMQAdapter } from './adapters/bullmq.js';
export { createLocalAdapter } from './adapters/local.js'; export { createLocalAdapter } from './adapters/local.js';
export { registerQueueCommand } from './cli.js';
import { registerQueueAdapter } from './factory.js'; import { registerQueueAdapter } from './factory.js';
import { createBullMQAdapter } from './adapters/bullmq.js'; import { createBullMQAdapter } from './adapters/bullmq.js';

12
pnpm-lock.yaml generated
View File

@@ -441,6 +441,9 @@ importers:
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
drizzle-orm: drizzle-orm:
specifier: ^0.45.1 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) 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,12 +472,18 @@ importers:
'@mosaicstack/macp': '@mosaicstack/macp':
specifier: workspace:* specifier: workspace:*
version: link:../macp version: link:../macp
'@mosaicstack/memory':
specifier: workspace:*
version: link:../memory
'@mosaicstack/prdy': '@mosaicstack/prdy':
specifier: workspace:* specifier: workspace:*
version: link:../prdy version: link:../prdy
'@mosaicstack/quality-rails': '@mosaicstack/quality-rails':
specifier: workspace:* specifier: workspace:*
version: link:../quality-rails version: link:../quality-rails
'@mosaicstack/queue':
specifier: workspace:*
version: link:../queue
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
@@ -571,6 +580,9 @@ importers:
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
ioredis: ioredis:
specifier: ^5.10.0 specifier: ^5.10.0
version: 5.10.0 version: 5.10.0