#!/usr/bin/env node import { Command } from 'commander'; import chalk from 'chalk'; import { formatDistanceToNow } from 'date-fns'; import { initDb, getDb } from './db/connection.js'; import { storeMemory, ValidationError } from './commands/store.js'; import { searchMemories } from './commands/search.js'; import { listMemories } from './commands/list.js'; import { pruneMemories } from './commands/prune.js'; import { deleteMemories } from './commands/delete.js'; import { parseTags } from './utils/tags.js'; const program = new Command(); function formatMemory(memory, options = {}) { const { json = false, markdown = false } = options; if (json) { return JSON.stringify(memory, null, 2); } const createdDate = new Date(memory.created_at * 1000); const createdStr = formatDistanceToNow(createdDate, { addSuffix: true }); let expiresStr = ''; if (memory.expires_at) { const expiresDate = new Date(memory.expires_at * 1000); expiresStr = formatDistanceToNow(expiresDate, { addSuffix: true }); } if (markdown) { let md = `## Memory #${memory.id}\n\n`; md += `${memory.content}\n\n`; md += `**Created**: ${createdStr} by ${memory.entered_by}\n`; if (memory.tags) md += `**Tags**: ${memory.tags}\n`; if (expiresStr) md += `**Expires**: ${expiresStr}\n`; return md; } let output = ''; output += chalk.blue.bold(`#${memory.id}`) + chalk.gray(` • ${createdStr} • ${memory.entered_by}\n`); output += `${memory.content}\n`; if (memory.tags) { const tagList = memory.tags.split(','); output += chalk.yellow(tagList.map(t => `#${t}`).join(' ')) + '\n'; } if (expiresStr) { output += chalk.red(`⏱ Expires ${expiresStr}\n`); } return output; } function formatMemoryList(memories, options = {}) { if (options.json) { return JSON.stringify(memories, null, 2); } if (memories.length === 0) { return chalk.gray('No memories found.'); } return memories.map(m => formatMemory(m, options)).join('\n' + chalk.gray('─'.repeat(60)) + '\n'); } function parseDate(dateStr) { if (!dateStr) return null; const date = new Date(dateStr); return Math.floor(date.getTime() / 1000); } program .name('llmemory') .description('LLMemory - AI Agent Memory System') .version('0.1.0'); program .command('store ') .description('Store a new memory') .option('-t, --tags ', 'Comma-separated tags') .option('-e, --expires ', 'Expiration date') .option('--by ', 'Agent/user identifier', 'manual') .action((content, options) => { try { initDb(); const db = getDb(); const memory = storeMemory(db, { content, tags: options.tags ? parseTags(options.tags) : null, expires_at: parseDate(options.expires), entered_by: options.by }); console.log(chalk.green('✓ Memory stored successfully')); console.log(formatMemory(memory)); } catch (error) { if (error instanceof ValidationError) { console.error(chalk.red('✗ Validation error:'), error.message); process.exit(1); } console.error(chalk.red('✗ Error:'), error.message); process.exit(1); } }); program .command('search ') .description('Search memories') .option('-t, --tags ', 'Filter by tags (AND)') .option('--any-tag ', 'Filter by tags (OR)') .option('--after ', 'Created after date') .option('--before ', 'Created before date') .option('--entered-by ', 'Filter by creator') .option('-l, --limit ', 'Max results', '10') .option('--offset ', 'Pagination offset', '0') .option('--json', 'Output as JSON') .option('--markdown', 'Output as Markdown') .action((query, options) => { try { initDb(); const db = getDb(); const searchOptions = { tags: options.tags ? parseTags(options.tags) : [], anyTag: !!options.anyTag, after: parseDate(options.after), before: parseDate(options.before), entered_by: options.enteredBy, limit: parseInt(options.limit), offset: parseInt(options.offset) }; if (options.anyTag) { searchOptions.tags = parseTags(options.anyTag); } const results = searchMemories(db, query, searchOptions); if (results.length === 0) { console.log(chalk.gray('No memories found matching your query.')); return; } console.log(chalk.green(`Found ${results.length} ${results.length === 1 ? 'memory' : 'memories'}\n`)); console.log(formatMemoryList(results, { json: options.json, markdown: options.markdown })); } catch (error) { console.error(chalk.red('✗ Error:'), error.message); process.exit(1); } }); program .command('list') .description('List recent memories') .option('-l, --limit ', 'Max results', '20') .option('--offset ', 'Pagination offset', '0') .option('-t, --tags ', 'Filter by tags') .option('--sort ', 'Sort by field (created, expires, content)', 'created') .option('--order ', 'Sort order (asc, desc)', 'desc') .option('--json', 'Output as JSON') .option('--markdown', 'Output as Markdown') .action((options) => { try { initDb(); const db = getDb(); const listOptions = { limit: parseInt(options.limit), offset: parseInt(options.offset), tags: options.tags ? parseTags(options.tags) : [], sort: options.sort, order: options.order }; const results = listMemories(db, listOptions); if (results.length === 0) { console.log(chalk.gray('No memories found.')); return; } console.log(chalk.green(`Listing ${results.length} ${results.length === 1 ? 'memory' : 'memories'}\n`)); console.log(formatMemoryList(results, { json: options.json, markdown: options.markdown })); } catch (error) { console.error(chalk.red('✗ Error:'), error.message); process.exit(1); } }); program .command('prune') .description('Remove expired memories') .option('--dry-run', 'Show what would be deleted without deleting') .option('--force', 'Skip confirmation prompt') .option('--before ', 'Delete memories before date (even if not expired)') .action(async (options) => { try { initDb(); const db = getDb(); const pruneOptions = { dryRun: options.dryRun || false, before: parseDate(options.before) }; const result = pruneMemories(db, pruneOptions); if (result.count === 0) { console.log(chalk.green('✓ No expired memories to prune.')); return; } if (pruneOptions.dryRun) { console.log(chalk.yellow(`Would delete ${result.count} ${result.count === 1 ? 'memory' : 'memories'}:\n`)); result.memories.forEach(m => { console.log(chalk.gray(` #${m.id}: ${m.content.substring(0, 60)}...`)); }); console.log(chalk.yellow('\nRun without --dry-run to actually delete.')); } else { if (!options.force) { console.log(chalk.yellow(`⚠ About to delete ${result.count} ${result.count === 1 ? 'memory' : 'memories'}.`)); console.log(chalk.gray('Run with --dry-run to preview first, or --force to skip this check.')); process.exit(0); } console.log(chalk.green(`✓ Pruned ${result.count} expired ${result.count === 1 ? 'memory' : 'memories'}.`)); } } catch (error) { console.error(chalk.red('✗ Error:'), error.message); process.exit(1); } }); program .command('delete') .description('Delete memories by various criteria') .option('--ids ', 'Comma-separated memory IDs to delete') .option('-t, --tags ', 'Filter by tags (AND logic)') .option('--any-tag ', 'Filter by tags (OR logic)') .option('-q, --query ', 'Delete memories matching text (LIKE search)') .option('--after ', 'Delete memories created after date') .option('--before ', 'Delete memories created before date') .option('--entered-by ', 'Delete memories by specific agent') .option('--include-expired', 'Include expired memories in deletion') .option('--expired-only', 'Delete only expired memories') .option('--dry-run', 'Show what would be deleted without deleting') .option('--json', 'Output as JSON') .option('--markdown', 'Output as Markdown') .action(async (options) => { try { initDb(); const db = getDb(); // Parse options const deleteOptions = { ids: options.ids ? options.ids.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id)) : [], tags: options.tags ? parseTags(options.tags) : [], anyTag: !!options.anyTag, query: options.query || null, after: parseDate(options.after), before: parseDate(options.before), entered_by: options.enteredBy, includeExpired: options.includeExpired || false, expiredOnly: options.expiredOnly || false, dryRun: options.dryRun || false }; if (options.anyTag) { deleteOptions.tags = parseTags(options.anyTag); } // Execute deletion const result = deleteMemories(db, deleteOptions); if (result.count === 0) { console.log(chalk.gray('No memories match the specified criteria.')); return; } if (deleteOptions.dryRun) { console.log(chalk.yellow(`Would delete ${result.count} ${result.count === 1 ? 'memory' : 'memories'}:\n`)); console.log(formatMemoryList(result.memories, { json: options.json, markdown: options.markdown })); console.log(chalk.yellow('\nRun without --dry-run to actually delete.')); } else { console.log(chalk.green(`✓ Deleted ${result.count} ${result.count === 1 ? 'memory' : 'memories'}.`)); } } catch (error) { if (error.message.includes('At least one filter')) { console.error(chalk.red('✗ Safety check:'), error.message); console.error(chalk.gray('\nAvailable filters: --ids, --tags, --query, --after, --before, --entered-by, --expired-only')); process.exit(1); } console.error(chalk.red('✗ Error:'), error.message); process.exit(1); } }); program .command('stats') .description('Show memory statistics') .option('--tags', 'Show tag frequency distribution') .option('--agents', 'Show memories per agent') .action((options) => { try { initDb(); const db = getDb(); const totalMemories = db.prepare('SELECT COUNT(*) as count FROM memories WHERE expires_at IS NULL OR expires_at > strftime(\'%s\', \'now\')').get(); const expiredMemories = db.prepare('SELECT COUNT(*) as count FROM memories WHERE expires_at IS NOT NULL AND expires_at <= strftime(\'%s\', \'now\')').get(); console.log(chalk.blue.bold('Memory Statistics\n')); console.log(`${chalk.green('Active memories:')} ${totalMemories.count}`); console.log(`${chalk.red('Expired memories:')} ${expiredMemories.count}`); if (options.tags) { console.log(chalk.blue.bold('\nTag Distribution:')); const tagStats = db.prepare(` SELECT t.name, COUNT(*) as count FROM tags t JOIN memory_tags mt ON t.id = mt.tag_id JOIN memories m ON mt.memory_id = m.id WHERE m.expires_at IS NULL OR m.expires_at > strftime('%s', 'now') GROUP BY t.name ORDER BY count DESC `).all(); if (tagStats.length === 0) { console.log(chalk.gray(' No tags found.')); } else { tagStats.forEach(({ name, count }) => { console.log(` ${chalk.yellow(`#${name}`)}: ${count}`); }); } } if (options.agents) { console.log(chalk.blue.bold('\nMemories by Agent:')); const agentStats = db.prepare(` SELECT entered_by, COUNT(*) as count FROM memories WHERE expires_at IS NULL OR expires_at > strftime('%s', 'now') GROUP BY entered_by ORDER BY count DESC `).all(); if (agentStats.length === 0) { console.log(chalk.gray(' No agents found.')); } else { agentStats.forEach(({ entered_by, count }) => { console.log(` ${chalk.cyan(entered_by)}: ${count}`); }); } } } catch (error) { console.error(chalk.red('✗ Error:'), error.message); process.exit(1); } }); program .command('export ') .description('Export memories to JSON file') .action((file) => { console.log(chalk.yellow('Export command - Phase 2 feature')); console.log('File:', file); }); program .command('import ') .description('Import memories from JSON file') .action((file) => { console.log(chalk.yellow('Import command - Phase 2 feature')); console.log('File:', file); }); // Global options program .option('--agent-context', 'Display comprehensive agent documentation') .option('--db ', 'Custom database location') .option('--verbose', 'Detailed logging') .option('--quiet', 'Suppress non-error output'); if (process.argv.includes('--agent-context')) { console.log(chalk.blue.bold('='.repeat(80))); console.log(chalk.blue.bold('LLMemory - Agent Context Documentation')); console.log(chalk.blue.bold('='.repeat(80))); console.log(chalk.white('\n📚 LLMemory is a persistent memory/journal system for AI agents.\n')); console.log(chalk.green.bold('QUICK START:')); console.log(chalk.white(' Store a memory:')); console.log(chalk.gray(' $ llmemory store "Completed authentication refactor" --tags backend,auth')); console.log(chalk.white('\n Search memories:')); console.log(chalk.gray(' $ llmemory search "authentication" --tags backend --limit 5')); console.log(chalk.white('\n List recent work:')); console.log(chalk.gray(' $ llmemory list --limit 10')); console.log(chalk.white('\n Remove old memories:')); console.log(chalk.gray(' $ llmemory prune --dry-run')); console.log(chalk.green.bold('\n\nCOMMAND REFERENCE:')); console.log(chalk.yellow(' store') + chalk.white(' Store a new memory')); console.log(chalk.gray(' -t, --tags Comma-separated tags')); console.log(chalk.gray(' -e, --expires Expiration date')); console.log(chalk.gray(' --by Agent/user identifier (default: manual)')); console.log(chalk.yellow('\n search') + chalk.white(' Search memories (case-insensitive)')); console.log(chalk.gray(' -t, --tags Filter by tags (AND)')); console.log(chalk.gray(' --any-tag Filter by tags (OR)')); console.log(chalk.gray(' --after Created after date')); console.log(chalk.gray(' --before Created before date')); console.log(chalk.gray(' --entered-by Filter by creator')); console.log(chalk.gray(' -l, --limit Max results (default: 10)')); console.log(chalk.gray(' --json Output as JSON')); console.log(chalk.gray(' --markdown Output as Markdown')); console.log(chalk.yellow('\n list') + chalk.white(' List recent memories')); console.log(chalk.gray(' -l, --limit Max results (default: 20)')); console.log(chalk.gray(' -t, --tags Filter by tags')); console.log(chalk.gray(' --sort Sort by: created, expires, content')); console.log(chalk.gray(' --order Sort order: asc, desc')); console.log(chalk.yellow('\n prune') + chalk.white(' Remove expired memories')); console.log(chalk.gray(' --dry-run Preview without deleting')); console.log(chalk.gray(' --force Skip confirmation')); console.log(chalk.gray(' --before Delete memories before date')); console.log(chalk.yellow('\n delete') + chalk.white(' Delete memories by criteria')); console.log(chalk.gray(' --ids Comma-separated memory IDs')); console.log(chalk.gray(' -t, --tags Filter by tags (AND logic)')); console.log(chalk.gray(' --any-tag Filter by tags (OR logic)')); console.log(chalk.gray(' -q, --query LIKE search on content')); console.log(chalk.gray(' --after Created after date')); console.log(chalk.gray(' --before Created before date')); console.log(chalk.gray(' --entered-by Filter by creator')); console.log(chalk.gray(' --include-expired Include expired memories')); console.log(chalk.gray(' --expired-only Delete only expired')); console.log(chalk.gray(' --dry-run Preview without deleting')); console.log(chalk.gray(' --force Skip confirmation')); console.log(chalk.yellow('\n stats') + chalk.white(' Show memory statistics')); console.log(chalk.gray(' --tags Show tag distribution')); console.log(chalk.gray(' --agents Show memories per agent')); console.log(chalk.green.bold('\n\nDESIGN PRINCIPLES:')); console.log(chalk.white(' • ') + chalk.gray('Sparse token usage - only returns relevant results')); console.log(chalk.white(' • ') + chalk.gray('Fast search - optimized LIKE queries, FTS5 ready')); console.log(chalk.white(' • ') + chalk.gray('Flexible tagging - organize with multiple tags')); console.log(chalk.white(' • ') + chalk.gray('Automatic cleanup - expire old memories')); console.log(chalk.white(' • ') + chalk.gray('Agent-agnostic - works across sessions')); console.log(chalk.blue('\n📖 For detailed docs, see:')); console.log(chalk.gray(' SPECIFICATION.md - Complete technical specification')); console.log(chalk.gray(' ARCHITECTURE.md - System design and algorithms')); console.log(chalk.gray(' docs/TESTING.md - TDD approach and test philosophy')); console.log(chalk.blue.bold('\n' + '='.repeat(80) + '\n')); process.exit(0); } program.parse();