nixos/shared/linked-dotfiles/opencode/llmemory/src/cli.js
2025-10-29 18:46:16 -06:00

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();