460 lines
18 KiB
JavaScript
460 lines
18 KiB
JavaScript
#!/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 <content>')
|
|
.description('Store a new memory')
|
|
.option('-t, --tags <tags>', 'Comma-separated tags')
|
|
.option('-e, --expires <date>', 'Expiration date')
|
|
.option('--by <agent>', '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 <query>')
|
|
.description('Search memories')
|
|
.option('-t, --tags <tags>', 'Filter by tags (AND)')
|
|
.option('--any-tag <tags>', 'Filter by tags (OR)')
|
|
.option('--after <date>', 'Created after date')
|
|
.option('--before <date>', 'Created before date')
|
|
.option('--entered-by <agent>', 'Filter by creator')
|
|
.option('-l, --limit <n>', 'Max results', '10')
|
|
.option('--offset <n>', '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 <n>', 'Max results', '20')
|
|
.option('--offset <n>', 'Pagination offset', '0')
|
|
.option('-t, --tags <tags>', 'Filter by tags')
|
|
.option('--sort <field>', 'Sort by field (created, expires, content)', 'created')
|
|
.option('--order <dir>', '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 <date>', '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 <ids>', 'Comma-separated memory IDs to delete')
|
|
.option('-t, --tags <tags>', 'Filter by tags (AND logic)')
|
|
.option('--any-tag <tags>', 'Filter by tags (OR logic)')
|
|
.option('-q, --query <text>', 'Delete memories matching text (LIKE search)')
|
|
.option('--after <date>', 'Delete memories created after date')
|
|
.option('--before <date>', 'Delete memories created before date')
|
|
.option('--entered-by <agent>', '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 <file>')
|
|
.description('Export memories to JSON file')
|
|
.action((file) => {
|
|
console.log(chalk.yellow('Export command - Phase 2 feature'));
|
|
console.log('File:', file);
|
|
});
|
|
|
|
program
|
|
.command('import <file>')
|
|
.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 <path>', '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(' <content> Store a new memory'));
|
|
console.log(chalk.gray(' -t, --tags <tags> Comma-separated tags'));
|
|
console.log(chalk.gray(' -e, --expires <date> Expiration date'));
|
|
console.log(chalk.gray(' --by <agent> Agent/user identifier (default: manual)'));
|
|
|
|
console.log(chalk.yellow('\n search') + chalk.white(' <query> Search memories (case-insensitive)'));
|
|
console.log(chalk.gray(' -t, --tags <tags> Filter by tags (AND)'));
|
|
console.log(chalk.gray(' --any-tag <tags> Filter by tags (OR)'));
|
|
console.log(chalk.gray(' --after <date> Created after date'));
|
|
console.log(chalk.gray(' --before <date> Created before date'));
|
|
console.log(chalk.gray(' --entered-by <agent> Filter by creator'));
|
|
console.log(chalk.gray(' -l, --limit <n> 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 <n> Max results (default: 20)'));
|
|
console.log(chalk.gray(' -t, --tags <tags> Filter by tags'));
|
|
console.log(chalk.gray(' --sort <field> Sort by: created, expires, content'));
|
|
console.log(chalk.gray(' --order <dir> 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 <date> Delete memories before date'));
|
|
|
|
console.log(chalk.yellow('\n delete') + chalk.white(' Delete memories by criteria'));
|
|
console.log(chalk.gray(' --ids <ids> Comma-separated memory IDs'));
|
|
console.log(chalk.gray(' -t, --tags <tags> Filter by tags (AND logic)'));
|
|
console.log(chalk.gray(' --any-tag <tags> Filter by tags (OR logic)'));
|
|
console.log(chalk.gray(' -q, --query <text> LIKE search on content'));
|
|
console.log(chalk.gray(' --after <date> Created after date'));
|
|
console.log(chalk.gray(' --before <date> Created before date'));
|
|
console.log(chalk.gray(' --entered-by <agent> 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();
|