mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-10 08:12:37 +02:00
1465 lines
50 KiB
TypeScript
1465 lines
50 KiB
TypeScript
|
|
#!/usr/bin/env node
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Engram CLI - Management commands for the Memory Palace
|
||
|
|
*
|
||
|
|
* Usage:
|
||
|
|
* engram stats - Show knowledge base statistics and health
|
||
|
|
* engram health - Detailed health check
|
||
|
|
* engram review - Start a review session
|
||
|
|
* engram people - List people in your network
|
||
|
|
* engram backup - Create a backup
|
||
|
|
* engram backups - List available backups
|
||
|
|
* engram restore <path> - Restore from a backup
|
||
|
|
* engram optimize - Optimize the database
|
||
|
|
* engram decay - Apply memory decay
|
||
|
|
* engram eat <url|path> - Ingest documentation/content (Man Page Absorber)
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { EngramDatabase, EngramDatabaseError } from './core/database.js';
|
||
|
|
import {
|
||
|
|
captureContext,
|
||
|
|
formatContextForInjection,
|
||
|
|
startContextWatcher,
|
||
|
|
readSavedContext,
|
||
|
|
} from './core/context-watcher.js';
|
||
|
|
import { runREMCycle, previewREMCycle } from './core/rem-cycle.js';
|
||
|
|
import { ShadowSelf, runShadowCycle } from './core/shadow-self.js';
|
||
|
|
import {
|
||
|
|
validatePath,
|
||
|
|
validateUrl,
|
||
|
|
sanitizeContent,
|
||
|
|
logSecurityEvent,
|
||
|
|
MAX_CONTENT_LENGTH,
|
||
|
|
} from './core/security.js';
|
||
|
|
import { runConsolidation } from './core/consolidation.js';
|
||
|
|
import { createEmbeddingService, OllamaEmbeddingService } from './core/embeddings.js';
|
||
|
|
import { getConfig, resetConfig, loadConfig } from './core/config.js';
|
||
|
|
import { createVectorStore, ChromaVectorStore } from './core/vector-store.js';
|
||
|
|
import fs from 'fs';
|
||
|
|
import path from 'path';
|
||
|
|
import os from 'os';
|
||
|
|
import { marked } from 'marked';
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// MAN PAGE ABSORBER - Feed your brain
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
interface ContentChunk {
|
||
|
|
title: string;
|
||
|
|
content: string;
|
||
|
|
section: string;
|
||
|
|
index: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Fetch content from URL (with SSRF protection)
|
||
|
|
*/
|
||
|
|
async function fetchUrl(url: string): Promise<string> {
|
||
|
|
// Validate URL to prevent SSRF attacks
|
||
|
|
const validation = validateUrl(url);
|
||
|
|
if (!validation.valid) {
|
||
|
|
logSecurityEvent({
|
||
|
|
type: 'ssrf_attempt',
|
||
|
|
details: { error: validation.error || 'URL validation failed', url: url.slice(0, 100) },
|
||
|
|
severity: 'high',
|
||
|
|
blocked: true,
|
||
|
|
});
|
||
|
|
throw new Error(`Security: ${validation.error}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const safeUrl = validation.sanitizedUrl!;
|
||
|
|
const response = await fetch(safeUrl, {
|
||
|
|
// Add timeout to prevent hanging on slow responses
|
||
|
|
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Failed to fetch ${safeUrl}: ${response.status} ${response.statusText}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check content length to prevent DoS
|
||
|
|
const contentLength = response.headers.get('content-length');
|
||
|
|
if (contentLength && parseInt(contentLength, 10) > MAX_CONTENT_LENGTH) {
|
||
|
|
throw new Error(`Content too large: ${contentLength} bytes exceeds ${MAX_CONTENT_LENGTH} byte limit`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const contentType = response.headers.get('content-type') || '';
|
||
|
|
|
||
|
|
let content: string;
|
||
|
|
if (contentType.includes('text/html')) {
|
||
|
|
// Strip HTML to get text content
|
||
|
|
const html = await response.text();
|
||
|
|
content = stripHtml(html);
|
||
|
|
} else {
|
||
|
|
content = await response.text();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sanitize the content
|
||
|
|
return sanitizeContent(content);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Simple HTML stripper - extracts text content
|
||
|
|
*/
|
||
|
|
function stripHtml(html: string): string {
|
||
|
|
// Remove script and style tags and their content
|
||
|
|
let text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||
|
|
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||
|
|
|
||
|
|
// Remove HTML tags
|
||
|
|
text = text.replace(/<[^>]+>/g, ' ');
|
||
|
|
|
||
|
|
// Decode HTML entities
|
||
|
|
text = text.replace(/ /g, ' ');
|
||
|
|
text = text.replace(/&/g, '&');
|
||
|
|
text = text.replace(/</g, '<');
|
||
|
|
text = text.replace(/>/g, '>');
|
||
|
|
text = text.replace(/"/g, '"');
|
||
|
|
text = text.replace(/'/g, "'");
|
||
|
|
|
||
|
|
// Clean up whitespace
|
||
|
|
text = text.replace(/\s+/g, ' ').trim();
|
||
|
|
|
||
|
|
return text;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Read content from file (with path traversal protection)
|
||
|
|
*/
|
||
|
|
async function readFile(filePath: string): Promise<string> {
|
||
|
|
// Validate path to prevent path traversal attacks
|
||
|
|
const validation = validatePath(filePath);
|
||
|
|
if (!validation.valid) {
|
||
|
|
logSecurityEvent({
|
||
|
|
type: 'path_traversal',
|
||
|
|
details: { error: validation.error || 'Path validation failed', path: filePath.slice(0, 100) },
|
||
|
|
severity: 'high',
|
||
|
|
blocked: true,
|
||
|
|
});
|
||
|
|
throw new Error(`Security: ${validation.error}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const safePath = validation.sanitizedPath!;
|
||
|
|
|
||
|
|
if (!fs.existsSync(safePath)) {
|
||
|
|
throw new Error(`File not found: ${safePath}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check file size before reading
|
||
|
|
const stats = fs.statSync(safePath);
|
||
|
|
if (stats.size > MAX_CONTENT_LENGTH) {
|
||
|
|
throw new Error(`File too large: ${stats.size} bytes exceeds ${MAX_CONTENT_LENGTH} byte limit`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const content = fs.readFileSync(safePath, 'utf-8');
|
||
|
|
|
||
|
|
// Sanitize the content
|
||
|
|
return sanitizeContent(content);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Chunk content intelligently
|
||
|
|
* - Respects markdown headers as section boundaries
|
||
|
|
* - Creates overlapping chunks for context preservation
|
||
|
|
* - Targets ~500-1000 tokens per chunk
|
||
|
|
*/
|
||
|
|
function chunkContent(content: string, source: string): ContentChunk[] {
|
||
|
|
const chunks: ContentChunk[] = [];
|
||
|
|
|
||
|
|
// Try to detect if it's markdown
|
||
|
|
const isMarkdown = content.includes('# ') || content.includes('## ') || content.includes('```');
|
||
|
|
|
||
|
|
if (isMarkdown) {
|
||
|
|
// Split by headers
|
||
|
|
const sections = content.split(/^(#{1,3} .+)$/m);
|
||
|
|
let currentSection = 'Introduction';
|
||
|
|
let currentContent = '';
|
||
|
|
let chunkIndex = 0;
|
||
|
|
|
||
|
|
for (let i = 0; i < sections.length; i++) {
|
||
|
|
const sectionRaw = sections[i];
|
||
|
|
if (!sectionRaw) continue;
|
||
|
|
const section = sectionRaw.trim();
|
||
|
|
if (!section) continue;
|
||
|
|
|
||
|
|
// Check if this is a header
|
||
|
|
if (section.match(/^#{1,3} /)) {
|
||
|
|
// Save previous section if it has content
|
||
|
|
if (currentContent.trim()) {
|
||
|
|
chunks.push(...splitLargeSection(currentContent, currentSection, chunkIndex, source));
|
||
|
|
chunkIndex = chunks.length;
|
||
|
|
}
|
||
|
|
currentSection = section.replace(/^#{1,3} /, '').trim();
|
||
|
|
currentContent = '';
|
||
|
|
} else {
|
||
|
|
currentContent += section + '\n\n';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Don't forget the last section
|
||
|
|
if (currentContent.trim()) {
|
||
|
|
chunks.push(...splitLargeSection(currentContent, currentSection, chunkIndex, source));
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Plain text - split by paragraphs
|
||
|
|
const paragraphs = content.split(/\n\n+/);
|
||
|
|
let currentChunk = '';
|
||
|
|
let chunkIndex = 0;
|
||
|
|
|
||
|
|
for (const para of paragraphs) {
|
||
|
|
const trimmed = para.trim();
|
||
|
|
if (!trimmed) continue;
|
||
|
|
|
||
|
|
if ((currentChunk + trimmed).length > 2000) {
|
||
|
|
if (currentChunk) {
|
||
|
|
chunks.push({
|
||
|
|
title: `Section ${chunkIndex + 1}`,
|
||
|
|
content: currentChunk.trim(),
|
||
|
|
section: source,
|
||
|
|
index: chunkIndex,
|
||
|
|
});
|
||
|
|
chunkIndex++;
|
||
|
|
}
|
||
|
|
currentChunk = trimmed + '\n\n';
|
||
|
|
} else {
|
||
|
|
currentChunk += trimmed + '\n\n';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentChunk.trim()) {
|
||
|
|
chunks.push({
|
||
|
|
title: `Section ${chunkIndex + 1}`,
|
||
|
|
content: currentChunk.trim(),
|
||
|
|
section: source,
|
||
|
|
index: chunkIndex,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return chunks;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Split large sections into smaller chunks
|
||
|
|
*/
|
||
|
|
function splitLargeSection(content: string, section: string, startIndex: number, source: string): ContentChunk[] {
|
||
|
|
const MAX_CHUNK_SIZE = 2000; // ~500 tokens
|
||
|
|
const chunks: ContentChunk[] = [];
|
||
|
|
|
||
|
|
if (content.length <= MAX_CHUNK_SIZE) {
|
||
|
|
chunks.push({
|
||
|
|
title: section,
|
||
|
|
content: content.trim(),
|
||
|
|
section: source,
|
||
|
|
index: startIndex,
|
||
|
|
});
|
||
|
|
return chunks;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Split by paragraphs
|
||
|
|
const paragraphs = content.split(/\n\n+/);
|
||
|
|
let currentChunk = '';
|
||
|
|
let partNumber = 1;
|
||
|
|
|
||
|
|
for (const para of paragraphs) {
|
||
|
|
const trimmed = para.trim();
|
||
|
|
if (!trimmed) continue;
|
||
|
|
|
||
|
|
if ((currentChunk + trimmed).length > MAX_CHUNK_SIZE) {
|
||
|
|
if (currentChunk) {
|
||
|
|
chunks.push({
|
||
|
|
title: `${section} (Part ${partNumber})`,
|
||
|
|
content: currentChunk.trim(),
|
||
|
|
section: source,
|
||
|
|
index: startIndex + chunks.length,
|
||
|
|
});
|
||
|
|
partNumber++;
|
||
|
|
}
|
||
|
|
currentChunk = trimmed + '\n\n';
|
||
|
|
} else {
|
||
|
|
currentChunk += trimmed + '\n\n';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentChunk.trim()) {
|
||
|
|
chunks.push({
|
||
|
|
title: partNumber > 1 ? `${section} (Part ${partNumber})` : section,
|
||
|
|
content: currentChunk.trim(),
|
||
|
|
section: source,
|
||
|
|
index: startIndex + chunks.length,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return chunks;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Ingest content from URL or file path
|
||
|
|
*/
|
||
|
|
async function eatContent(source: string, db: EngramDatabase): Promise<void> {
|
||
|
|
console.log(`\n Fetching content from: ${source}`);
|
||
|
|
|
||
|
|
// Determine if URL or file
|
||
|
|
const isUrl = source.startsWith('http://') || source.startsWith('https://');
|
||
|
|
let content: string;
|
||
|
|
let sourceType: 'webpage' | 'article' = 'webpage';
|
||
|
|
let sourceName: string;
|
||
|
|
|
||
|
|
if (isUrl) {
|
||
|
|
content = await fetchUrl(source);
|
||
|
|
sourceName = new URL(source).hostname + new URL(source).pathname;
|
||
|
|
} else {
|
||
|
|
content = await readFile(source);
|
||
|
|
sourceName = path.basename(source);
|
||
|
|
sourceType = 'article'; // Local files are treated as articles
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(` Content length: ${content.length} characters`);
|
||
|
|
|
||
|
|
// Chunk the content
|
||
|
|
const chunks = chunkContent(content, sourceName);
|
||
|
|
console.log(` Created ${chunks.length} knowledge chunks`);
|
||
|
|
|
||
|
|
if (chunks.length === 0) {
|
||
|
|
console.log(' No content to ingest.\n');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ingest each chunk
|
||
|
|
console.log('\n Ingesting chunks:');
|
||
|
|
const nodeIds: string[] = [];
|
||
|
|
|
||
|
|
for (const chunk of chunks) {
|
||
|
|
const node = db.insertNode({
|
||
|
|
content: chunk.content,
|
||
|
|
summary: chunk.title,
|
||
|
|
sourceType: sourceType,
|
||
|
|
sourcePlatform: isUrl ? 'browser' : 'manual',
|
||
|
|
sourceUrl: isUrl ? source : undefined,
|
||
|
|
createdAt: new Date(),
|
||
|
|
updatedAt: new Date(),
|
||
|
|
lastAccessedAt: new Date(),
|
||
|
|
accessCount: 0,
|
||
|
|
retentionStrength: 1.0,
|
||
|
|
stabilityFactor: 1.0,
|
||
|
|
reviewCount: 0,
|
||
|
|
confidence: 0.9, // Ingested docs are high confidence
|
||
|
|
isContradicted: false,
|
||
|
|
contradictionIds: [],
|
||
|
|
people: [],
|
||
|
|
concepts: [],
|
||
|
|
events: [],
|
||
|
|
tags: ['ingested', chunk.section.toLowerCase().replace(/[^a-z0-9]+/g, '-')],
|
||
|
|
sourceChain: [source],
|
||
|
|
});
|
||
|
|
|
||
|
|
nodeIds.push(node.id);
|
||
|
|
console.log(` [${node.id.slice(0, 8)}] ${chunk.title.slice(0, 50)}${chunk.title.length > 50 ? '...' : ''}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create edges between sequential chunks (they're related!)
|
||
|
|
console.log('\n Creating knowledge connections...');
|
||
|
|
let edgesCreated = 0;
|
||
|
|
for (let i = 0; i < nodeIds.length - 1; i++) {
|
||
|
|
const fromId = nodeIds[i];
|
||
|
|
const toId = nodeIds[i + 1];
|
||
|
|
if (!fromId || !toId) continue;
|
||
|
|
|
||
|
|
try {
|
||
|
|
db.insertEdge({
|
||
|
|
fromId,
|
||
|
|
toId,
|
||
|
|
edgeType: 'follows',
|
||
|
|
weight: 0.8,
|
||
|
|
metadata: { source: 'ingestion', order: i },
|
||
|
|
createdAt: new Date(),
|
||
|
|
});
|
||
|
|
edgesCreated++;
|
||
|
|
} catch {
|
||
|
|
// Edge might already exist
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(` Created ${edgesCreated} sequential connections`);
|
||
|
|
console.log(`\n Successfully ingested ${chunks.length} chunks from ${sourceName}`);
|
||
|
|
console.log(` Use 'engram recall' or ask Claude to find this knowledge.\n`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const command = process.argv[2];
|
||
|
|
const args = process.argv.slice(3);
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
const db = new EngramDatabase();
|
||
|
|
|
||
|
|
try {
|
||
|
|
switch (command) {
|
||
|
|
case 'stats': {
|
||
|
|
const detailed = args[0] === 'detailed';
|
||
|
|
const stats = db.getStats();
|
||
|
|
const health = db.checkHealth();
|
||
|
|
const size = db.getDatabaseSize();
|
||
|
|
|
||
|
|
console.log('\n Memory Statistics');
|
||
|
|
console.log(' -----------------');
|
||
|
|
console.log(` Status: ${getStatusEmoji(health.status)} ${health.status.toUpperCase()}`);
|
||
|
|
console.log(` Total nodes: ${stats.totalNodes}`);
|
||
|
|
console.log(` Total people: ${stats.totalPeople}`);
|
||
|
|
console.log(` Total connections: ${stats.totalEdges}`);
|
||
|
|
console.log(` Database Size: ${size.formatted}`);
|
||
|
|
console.log(` Last Backup: ${health.lastBackup || 'Never'}`);
|
||
|
|
|
||
|
|
if (health.warnings.length > 0) {
|
||
|
|
console.log('\n Warnings:');
|
||
|
|
for (const warning of health.warnings) {
|
||
|
|
console.log(` - ${warning}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const decaying = db.getDecayingNodes(0.5, { limit: 100 });
|
||
|
|
console.log(`\n Knowledge needing review: ${decaying.total} nodes`);
|
||
|
|
|
||
|
|
if (detailed) {
|
||
|
|
// Retention strength distribution
|
||
|
|
console.log('\n Retention Strength Distribution');
|
||
|
|
console.log(' --------------------------------');
|
||
|
|
|
||
|
|
const allNodes = db.getRecentNodes({ limit: 10000 });
|
||
|
|
const distribution = {
|
||
|
|
strong: 0, // 0.8-1.0
|
||
|
|
good: 0, // 0.6-0.8
|
||
|
|
moderate: 0, // 0.4-0.6
|
||
|
|
weak: 0, // 0.2-0.4
|
||
|
|
fading: 0, // 0.0-0.2
|
||
|
|
};
|
||
|
|
|
||
|
|
for (const node of allNodes.items) {
|
||
|
|
const strength = node.retentionStrength;
|
||
|
|
if (strength >= 0.8) distribution.strong++;
|
||
|
|
else if (strength >= 0.6) distribution.good++;
|
||
|
|
else if (strength >= 0.4) distribution.moderate++;
|
||
|
|
else if (strength >= 0.2) distribution.weak++;
|
||
|
|
else distribution.fading++;
|
||
|
|
}
|
||
|
|
|
||
|
|
const total = allNodes.items.length || 1;
|
||
|
|
console.log(` Strong (80-100%): ${distribution.strong} (${((distribution.strong / total) * 100).toFixed(1)}%)`);
|
||
|
|
console.log(` Good (60-80%): ${distribution.good} (${((distribution.good / total) * 100).toFixed(1)}%)`);
|
||
|
|
console.log(` Moderate (40-60%): ${distribution.moderate} (${((distribution.moderate / total) * 100).toFixed(1)}%)`);
|
||
|
|
console.log(` Weak (20-40%): ${distribution.weak} (${((distribution.weak / total) * 100).toFixed(1)}%)`);
|
||
|
|
console.log(` Fading (0-20%): ${distribution.fading} (${((distribution.fading / total) * 100).toFixed(1)}%)`);
|
||
|
|
|
||
|
|
// Source type breakdown
|
||
|
|
console.log('\n Source Type Breakdown');
|
||
|
|
console.log(' ---------------------');
|
||
|
|
|
||
|
|
const sourceTypes: Record<string, number> = {};
|
||
|
|
for (const node of allNodes.items) {
|
||
|
|
const type = node.sourceType || 'unknown';
|
||
|
|
sourceTypes[type] = (sourceTypes[type] || 0) + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
const sortedTypes = Object.entries(sourceTypes).sort((a, b) => b[1] - a[1]);
|
||
|
|
for (const [type, count] of sortedTypes.slice(0, 10)) {
|
||
|
|
console.log(` ${type.padEnd(15)} ${count}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Services status
|
||
|
|
console.log('\n Services Status');
|
||
|
|
console.log(' ---------------');
|
||
|
|
|
||
|
|
try {
|
||
|
|
const embService = new OllamaEmbeddingService();
|
||
|
|
const embAvailable = await embService.isAvailable();
|
||
|
|
console.log(` Embeddings: ${embAvailable ? 'Available (Ollama)' : 'Fallback mode'}`);
|
||
|
|
} catch {
|
||
|
|
console.log(' Embeddings: Check failed');
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const chromaStore = new ChromaVectorStore();
|
||
|
|
const vecAvailable = await chromaStore.isAvailable();
|
||
|
|
if (vecAvailable) {
|
||
|
|
const vecStats = await chromaStore.getStats();
|
||
|
|
console.log(` Vector Store: ChromaDB (${vecStats.embeddingCount} embeddings)`);
|
||
|
|
} else {
|
||
|
|
console.log(' Vector Store: SQLite fallback');
|
||
|
|
}
|
||
|
|
await chromaStore.close();
|
||
|
|
} catch {
|
||
|
|
console.log(' Vector Store: Check failed');
|
||
|
|
}
|
||
|
|
|
||
|
|
// FSRS config
|
||
|
|
const config = getConfig();
|
||
|
|
console.log(` FSRS Retention: ${(config.fsrs.desiredRetention * 100).toFixed(0)}%`);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'health': {
|
||
|
|
const health = db.checkHealth();
|
||
|
|
const size = db.getDatabaseSize();
|
||
|
|
|
||
|
|
console.log('\n Engram Health Check\n');
|
||
|
|
console.log(` Status: ${getStatusEmoji(health.status)} ${health.status.toUpperCase()}`);
|
||
|
|
console.log(` Database Path: ${health.dbPath}`);
|
||
|
|
console.log(` Database Size: ${size.formatted}`);
|
||
|
|
console.log(` WAL Mode: ${health.walMode ? 'Enabled' : 'Disabled'}`);
|
||
|
|
console.log(` Integrity Check: ${health.integrityCheck ? 'Passed' : 'FAILED'}`);
|
||
|
|
console.log(` Node Count: ${health.nodeCount}`);
|
||
|
|
console.log(` People Count: ${health.peopleCount}`);
|
||
|
|
console.log(` Edge Count: ${health.edgeCount}`);
|
||
|
|
console.log(` Last Backup: ${health.lastBackup || 'Never'}`);
|
||
|
|
|
||
|
|
if (health.warnings.length > 0) {
|
||
|
|
console.log('\n Warnings:');
|
||
|
|
for (const warning of health.warnings) {
|
||
|
|
console.log(` - ${warning}`);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
console.log('\n No warnings - everything looks good!');
|
||
|
|
}
|
||
|
|
console.log();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'review': {
|
||
|
|
const decaying = db.getDecayingNodes(0.5, { limit: 10 });
|
||
|
|
if (decaying.items.length === 0) {
|
||
|
|
console.log('\n No knowledge needs review right now!\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('\n Knowledge Due for Review\n');
|
||
|
|
console.log(` Showing ${decaying.items.length} of ${decaying.total} items\n`);
|
||
|
|
|
||
|
|
for (const node of decaying.items) {
|
||
|
|
console.log(` [${node.id.slice(0, 8)}] ${node.content.slice(0, 80)}...`);
|
||
|
|
console.log(` Retention: ${(node.retentionStrength * 100).toFixed(1)}%`);
|
||
|
|
const daysSince = Math.floor((Date.now() - node.lastAccessedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||
|
|
console.log(` Last accessed: ${daysSince} days ago`);
|
||
|
|
console.log();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (decaying.hasMore) {
|
||
|
|
console.log(` ... and ${decaying.total - decaying.items.length} more items need review\n`);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'people': {
|
||
|
|
const result = db.getAllPeople({ limit: 50 });
|
||
|
|
if (result.items.length === 0) {
|
||
|
|
console.log('\n No people in your network yet.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('\n Your Network\n');
|
||
|
|
console.log(` Showing ${result.items.length} of ${result.total} people\n`);
|
||
|
|
|
||
|
|
for (const person of result.items) {
|
||
|
|
const daysSince = person.lastContactAt
|
||
|
|
? Math.floor((Date.now() - person.lastContactAt.getTime()) / (1000 * 60 * 60 * 24))
|
||
|
|
: null;
|
||
|
|
console.log(` ${person.name}`);
|
||
|
|
if (person.organization) console.log(` Organization: ${person.organization}`);
|
||
|
|
if (person.relationshipType) console.log(` Relationship: ${person.relationshipType}`);
|
||
|
|
if (daysSince !== null) console.log(` Last contact: ${daysSince} days ago`);
|
||
|
|
if (person.sharedTopics.length > 0) console.log(` Topics: ${person.sharedTopics.join(', ')}`);
|
||
|
|
console.log();
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'backup': {
|
||
|
|
console.log('\n Creating backup...');
|
||
|
|
const backupPath = db.backup();
|
||
|
|
console.log(` Backup created: ${backupPath}`);
|
||
|
|
|
||
|
|
const backups = db.listBackups();
|
||
|
|
console.log(`\n Total backups: ${backups.length}`);
|
||
|
|
console.log(' Recent backups:');
|
||
|
|
for (const backup of backups.slice(0, 3)) {
|
||
|
|
console.log(` - ${backup.path}`);
|
||
|
|
console.log(` Size: ${(backup.size / 1024 / 1024).toFixed(2)}MB`);
|
||
|
|
console.log(` Date: ${backup.date.toISOString()}`);
|
||
|
|
}
|
||
|
|
console.log();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'backups': {
|
||
|
|
const backups = db.listBackups();
|
||
|
|
if (backups.length === 0) {
|
||
|
|
console.log('\n No backups found. Create one with: engram backup\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('\n Available Backups\n');
|
||
|
|
for (const backup of backups) {
|
||
|
|
console.log(` ${backup.path}`);
|
||
|
|
console.log(` Size: ${(backup.size / 1024 / 1024).toFixed(2)}MB`);
|
||
|
|
console.log(` Date: ${backup.date.toISOString()}`);
|
||
|
|
console.log();
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'restore': {
|
||
|
|
const backupPath = args[0];
|
||
|
|
if (!backupPath) {
|
||
|
|
console.log('\n Usage: engram restore <backup-path>');
|
||
|
|
console.log(' Use "engram backups" to see available backups.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate path to prevent path traversal attacks
|
||
|
|
const pathValidation = validatePath(backupPath);
|
||
|
|
if (!pathValidation.valid) {
|
||
|
|
logSecurityEvent({
|
||
|
|
type: 'path_traversal',
|
||
|
|
details: { error: pathValidation.error || 'Path validation failed', path: backupPath.slice(0, 100) },
|
||
|
|
severity: 'high',
|
||
|
|
blocked: true,
|
||
|
|
});
|
||
|
|
console.error(`\n Security Error: ${pathValidation.error}\n`);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
const safePath = pathValidation.sanitizedPath!;
|
||
|
|
console.log(`\n Restoring from: ${safePath}`);
|
||
|
|
console.log(' WARNING: This will replace your current database!\n');
|
||
|
|
|
||
|
|
// In a real CLI, you'd prompt for confirmation here
|
||
|
|
// For now, we just do it
|
||
|
|
try {
|
||
|
|
db.restore(safePath);
|
||
|
|
console.log(' Restore completed successfully!\n');
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof EngramDatabaseError) {
|
||
|
|
console.error(` Error: ${error.message} (${error.code})\n`);
|
||
|
|
} else {
|
||
|
|
console.error(` Error: ${error instanceof Error ? error.message : 'Unknown error'}\n`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'optimize': {
|
||
|
|
console.log('\n Optimizing database...');
|
||
|
|
const sizeBefore = db.getDatabaseSize();
|
||
|
|
db.optimize();
|
||
|
|
const sizeAfter = db.getDatabaseSize();
|
||
|
|
|
||
|
|
console.log(` Size before: ${sizeBefore.formatted}`);
|
||
|
|
console.log(` Size after: ${sizeAfter.formatted}`);
|
||
|
|
console.log(` Space saved: ${(sizeBefore.mb - sizeAfter.mb).toFixed(2)}MB`);
|
||
|
|
console.log();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'decay': {
|
||
|
|
console.log('\n Applying memory decay...');
|
||
|
|
const updated = db.applyDecay();
|
||
|
|
console.log(` Updated ${updated} knowledge nodes\n`);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'consolidate':
|
||
|
|
case 'sleep': {
|
||
|
|
console.log('\n Running sleep consolidation cycle...\n');
|
||
|
|
const consResult = await runConsolidation(db);
|
||
|
|
console.log(` Short-term processed: ${consResult.shortTermProcessed}`);
|
||
|
|
console.log(` Promoted to long-term: ${consResult.promotedToLongTerm}`);
|
||
|
|
console.log(` Connections discovered: ${consResult.connectionsDiscovered}`);
|
||
|
|
console.log(` Edges pruned: ${consResult.edgesPruned}`);
|
||
|
|
console.log(` Decay applied: ${consResult.decayApplied}`);
|
||
|
|
console.log(`\n Duration: ${consResult.duration}ms\n`);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'embeddings': {
|
||
|
|
const embCmd = args[0];
|
||
|
|
|
||
|
|
switch (embCmd) {
|
||
|
|
case 'status': {
|
||
|
|
console.log('\n Embedding Service Status\n');
|
||
|
|
const embService = new OllamaEmbeddingService();
|
||
|
|
const available = await embService.isAvailable();
|
||
|
|
console.log(` Service: ${available ? 'Available' : 'Not available'}`);
|
||
|
|
if (available) {
|
||
|
|
console.log(` Provider: Ollama`);
|
||
|
|
console.log(` Model: ${embService.getModel()}`);
|
||
|
|
console.log(` Host: ${process.env['OLLAMA_HOST'] || 'http://localhost:11434'}`);
|
||
|
|
} else {
|
||
|
|
console.log('\n To enable embeddings:');
|
||
|
|
console.log(' 1. Install Ollama: https://ollama.ai');
|
||
|
|
console.log(' 2. Run: ollama pull nomic-embed-text');
|
||
|
|
console.log(' 3. Start Ollama service');
|
||
|
|
}
|
||
|
|
console.log();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'generate': {
|
||
|
|
const nodeId = args[1];
|
||
|
|
console.log('\n Generating Embeddings\n');
|
||
|
|
|
||
|
|
try {
|
||
|
|
const embService = await createEmbeddingService();
|
||
|
|
|
||
|
|
if (nodeId) {
|
||
|
|
// Generate for specific node
|
||
|
|
const node = db.getNode(nodeId);
|
||
|
|
if (!node) {
|
||
|
|
console.log(` Error: Node not found: ${nodeId}\n`);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(` Generating embedding for node: ${nodeId.slice(0, 8)}...`);
|
||
|
|
const embedding = await embService.generateEmbedding(node.content);
|
||
|
|
console.log(` Embedding generated: ${embedding.length} dimensions`);
|
||
|
|
console.log(` First 5 values: [${embedding.slice(0, 5).map(v => v.toFixed(4)).join(', ')}...]`);
|
||
|
|
} else {
|
||
|
|
// Generate for all nodes without embeddings
|
||
|
|
console.log(' Generating embeddings for all nodes...');
|
||
|
|
const allNodes = db.getRecentNodes({ limit: 1000 });
|
||
|
|
let generated = 0;
|
||
|
|
let failed = 0;
|
||
|
|
|
||
|
|
for (const node of allNodes.items) {
|
||
|
|
try {
|
||
|
|
await embService.generateEmbedding(node.content);
|
||
|
|
generated++;
|
||
|
|
if (generated % 10 === 0) {
|
||
|
|
process.stdout.write(`\r Progress: ${generated}/${allNodes.items.length}`);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
failed++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
console.log(`\n Generated: ${generated}, Failed: ${failed}`);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.log(` Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
|
|
}
|
||
|
|
console.log();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'search': {
|
||
|
|
const query = args.slice(1).join(' ');
|
||
|
|
if (!query) {
|
||
|
|
console.log('\n Usage: engram embeddings search "<query>"\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`\n Semantic Search: "${query}"\n`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const embService = await createEmbeddingService();
|
||
|
|
const queryEmbedding = await embService.generateEmbedding(query);
|
||
|
|
|
||
|
|
// Get all nodes and compute similarity
|
||
|
|
const allNodes = db.getRecentNodes({ limit: 500 });
|
||
|
|
const results: Array<{ node: typeof allNodes.items[0]; similarity: number }> = [];
|
||
|
|
|
||
|
|
for (const node of allNodes.items) {
|
||
|
|
try {
|
||
|
|
const nodeEmbedding = await embService.generateEmbedding(node.content);
|
||
|
|
const similarity = embService.getSimilarity(queryEmbedding, nodeEmbedding);
|
||
|
|
results.push({ node, similarity });
|
||
|
|
} catch {
|
||
|
|
// Skip nodes that fail to embed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sort by similarity and show top 10
|
||
|
|
results.sort((a, b) => b.similarity - a.similarity);
|
||
|
|
const topResults = results.slice(0, 10);
|
||
|
|
|
||
|
|
if (topResults.length === 0) {
|
||
|
|
console.log(' No results found.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(' Top Results:');
|
||
|
|
for (const { node, similarity } of topResults) {
|
||
|
|
const preview = node.content.slice(0, 60).replace(/\n/g, ' ');
|
||
|
|
console.log(` [${(similarity * 100).toFixed(1)}%] ${preview}...`);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.log(` Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
|
|
}
|
||
|
|
console.log();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
default:
|
||
|
|
console.log(`
|
||
|
|
Engram Embeddings - Semantic Understanding
|
||
|
|
|
||
|
|
Usage: engram embeddings <command>
|
||
|
|
|
||
|
|
Commands:
|
||
|
|
status Check embedding service availability
|
||
|
|
generate [nodeId] Generate embeddings (all nodes or specific)
|
||
|
|
search "<query>" Semantic similarity search
|
||
|
|
|
||
|
|
Examples:
|
||
|
|
engram embeddings status
|
||
|
|
engram embeddings generate
|
||
|
|
engram embeddings generate abc12345
|
||
|
|
engram embeddings search "authentication flow"
|
||
|
|
`);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'config': {
|
||
|
|
const configCmd = args[0];
|
||
|
|
const configPath = path.join(os.homedir(), '.engram', 'config.json');
|
||
|
|
|
||
|
|
switch (configCmd) {
|
||
|
|
case 'show': {
|
||
|
|
console.log('\n Engram Configuration\n');
|
||
|
|
const config = getConfig();
|
||
|
|
console.log(JSON.stringify(config, null, 2));
|
||
|
|
console.log(`\n Config file: ${configPath}\n`);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'set': {
|
||
|
|
const key = args[1];
|
||
|
|
const value = args.slice(2).join(' ');
|
||
|
|
|
||
|
|
if (!key || !value) {
|
||
|
|
console.log('\n Usage: engram config set <section.key> <value>');
|
||
|
|
console.log('\n Examples:');
|
||
|
|
console.log(' engram config set logging.level debug');
|
||
|
|
console.log(' engram config set fsrs.desiredRetention 0.85');
|
||
|
|
console.log(' engram config set rem.enabled false\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`\n Setting ${key} = ${value}\n`);
|
||
|
|
|
||
|
|
// Load existing config or create empty
|
||
|
|
let fileConfig: Record<string, unknown> = {};
|
||
|
|
if (fs.existsSync(configPath)) {
|
||
|
|
try {
|
||
|
|
fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
|
|
} catch {
|
||
|
|
console.log(' Warning: Could not parse existing config, starting fresh');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse the key path (e.g., "logging.level")
|
||
|
|
const keyParts = key.split('.');
|
||
|
|
let current: Record<string, unknown> = fileConfig;
|
||
|
|
|
||
|
|
for (let i = 0; i < keyParts.length - 1; i++) {
|
||
|
|
const part = keyParts[i]!;
|
||
|
|
if (!(part in current) || typeof current[part] !== 'object') {
|
||
|
|
current[part] = {};
|
||
|
|
}
|
||
|
|
current = current[part] as Record<string, unknown>;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse value (try as JSON, fall back to string)
|
||
|
|
let parsedValue: unknown = value;
|
||
|
|
try {
|
||
|
|
parsedValue = JSON.parse(value);
|
||
|
|
} catch {
|
||
|
|
// Keep as string
|
||
|
|
}
|
||
|
|
|
||
|
|
current[keyParts[keyParts.length - 1]!] = parsedValue;
|
||
|
|
|
||
|
|
// Ensure directory exists
|
||
|
|
const configDir = path.dirname(configPath);
|
||
|
|
if (!fs.existsSync(configDir)) {
|
||
|
|
fs.mkdirSync(configDir, { recursive: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Write config
|
||
|
|
fs.writeFileSync(configPath, JSON.stringify(fileConfig, null, 2));
|
||
|
|
|
||
|
|
// Reset singleton to reload
|
||
|
|
resetConfig();
|
||
|
|
|
||
|
|
console.log(` Configuration updated: ${key} = ${JSON.stringify(parsedValue)}`);
|
||
|
|
console.log(` Saved to: ${configPath}\n`);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'reset': {
|
||
|
|
console.log('\n Resetting configuration to defaults...\n');
|
||
|
|
|
||
|
|
if (fs.existsSync(configPath)) {
|
||
|
|
// Create backup before deleting
|
||
|
|
const backupPath = `${configPath}.backup.${Date.now()}`;
|
||
|
|
fs.copyFileSync(configPath, backupPath);
|
||
|
|
console.log(` Backup created: ${backupPath}`);
|
||
|
|
|
||
|
|
fs.unlinkSync(configPath);
|
||
|
|
console.log(` Removed: ${configPath}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
resetConfig();
|
||
|
|
console.log(' Configuration reset to defaults.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
default:
|
||
|
|
console.log(`
|
||
|
|
Engram Configuration Management
|
||
|
|
|
||
|
|
Usage: engram config <command>
|
||
|
|
|
||
|
|
Commands:
|
||
|
|
show Display current configuration
|
||
|
|
set <key> <value> Update a configuration value
|
||
|
|
reset Reset to default configuration
|
||
|
|
|
||
|
|
Examples:
|
||
|
|
engram config show
|
||
|
|
engram config set logging.level debug
|
||
|
|
engram config set fsrs.desiredRetention 0.85
|
||
|
|
engram config reset
|
||
|
|
|
||
|
|
Configuration Sections:
|
||
|
|
database - Database paths and settings
|
||
|
|
fsrs - Spaced repetition algorithm
|
||
|
|
memory - Dual-strength memory model
|
||
|
|
rem - REM cycle settings
|
||
|
|
consolidation - Sleep consolidation
|
||
|
|
embeddings - Embedding service
|
||
|
|
vectorStore - Vector database
|
||
|
|
logging - Log levels
|
||
|
|
limits - Size limits
|
||
|
|
`);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'test': {
|
||
|
|
console.log('\n Engram Self-Test Suite\n');
|
||
|
|
console.log(' Running diagnostic tests...\n');
|
||
|
|
|
||
|
|
let allPassed = true;
|
||
|
|
|
||
|
|
// Test 1: Database
|
||
|
|
try {
|
||
|
|
const stats = db.getStats();
|
||
|
|
console.log(` [PASS] Database: ${stats.totalNodes} nodes, ${stats.totalPeople} people, ${stats.totalEdges} edges`);
|
||
|
|
} catch (error) {
|
||
|
|
console.log(` [FAIL] Database: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
|
|
allPassed = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test 2: Embeddings
|
||
|
|
try {
|
||
|
|
const embService = new OllamaEmbeddingService();
|
||
|
|
const embAvailable = await embService.isAvailable();
|
||
|
|
if (embAvailable) {
|
||
|
|
console.log(` [PASS] Embeddings: Ollama available (${embService.getModel()})`);
|
||
|
|
} else {
|
||
|
|
console.log(' [WARN] Embeddings: Ollama not available (fallback will be used)');
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.log(` [WARN] Embeddings: ${error instanceof Error ? error.message : 'Check failed'}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test 3: Vector Store
|
||
|
|
try {
|
||
|
|
const chromaStore = new ChromaVectorStore();
|
||
|
|
const vecAvailable = await chromaStore.isAvailable();
|
||
|
|
if (vecAvailable) {
|
||
|
|
const vecStats = await chromaStore.getStats();
|
||
|
|
console.log(` [PASS] Vector Store: ChromaDB available (${vecStats.embeddingCount} embeddings)`);
|
||
|
|
} else {
|
||
|
|
console.log(' [WARN] Vector Store: ChromaDB not available (SQLite fallback will be used)');
|
||
|
|
}
|
||
|
|
await chromaStore.close();
|
||
|
|
} catch (error) {
|
||
|
|
console.log(` [WARN] Vector Store: ${error instanceof Error ? error.message : 'Check failed'}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test 4: Configuration
|
||
|
|
try {
|
||
|
|
const config = getConfig();
|
||
|
|
console.log(` [PASS] Configuration: Loaded (FSRS retention: ${config.fsrs.desiredRetention})`);
|
||
|
|
} catch (error) {
|
||
|
|
console.log(` [FAIL] Configuration: ${error instanceof Error ? error.message : 'Load failed'}`);
|
||
|
|
allPassed = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test 5: Database health
|
||
|
|
try {
|
||
|
|
const health = db.checkHealth();
|
||
|
|
if (health.status === 'healthy') {
|
||
|
|
console.log(` [PASS] Health Check: ${health.status}`);
|
||
|
|
} else if (health.status === 'warning') {
|
||
|
|
console.log(` [WARN] Health Check: ${health.warnings.length} warning(s)`);
|
||
|
|
} else {
|
||
|
|
console.log(` [FAIL] Health Check: ${health.status}`);
|
||
|
|
allPassed = false;
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.log(` [FAIL] Health Check: ${error instanceof Error ? error.message : 'Check failed'}`);
|
||
|
|
allPassed = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log();
|
||
|
|
if (allPassed) {
|
||
|
|
console.log(' All core tests passed!\n');
|
||
|
|
} else {
|
||
|
|
console.log(' Some tests failed. Review the output above.\n');
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'ingest': {
|
||
|
|
const content = args.join(' ');
|
||
|
|
if (!content) {
|
||
|
|
console.log('\n Usage: engram ingest "<content>"');
|
||
|
|
console.log('\n Store knowledge directly into Engram.');
|
||
|
|
console.log('\n Examples:');
|
||
|
|
console.log(' engram ingest "API rate limit is 100 req/min"');
|
||
|
|
console.log(' engram ingest "Meeting with John: discussed Q4 roadmap"\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('\n Ingesting knowledge...\n');
|
||
|
|
|
||
|
|
try {
|
||
|
|
const node = db.insertNode({
|
||
|
|
content: content,
|
||
|
|
summary: content.slice(0, 100),
|
||
|
|
sourceType: 'note',
|
||
|
|
sourcePlatform: 'manual',
|
||
|
|
createdAt: new Date(),
|
||
|
|
updatedAt: new Date(),
|
||
|
|
lastAccessedAt: new Date(),
|
||
|
|
accessCount: 0,
|
||
|
|
retentionStrength: 1.0,
|
||
|
|
stabilityFactor: 1.0,
|
||
|
|
reviewCount: 0,
|
||
|
|
confidence: 0.9,
|
||
|
|
isContradicted: false,
|
||
|
|
contradictionIds: [],
|
||
|
|
people: [],
|
||
|
|
concepts: [],
|
||
|
|
events: [],
|
||
|
|
tags: ['cli-ingested'],
|
||
|
|
sourceChain: ['cli'],
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log(` Stored as node: ${node.id}`);
|
||
|
|
console.log(` Content: "${content.slice(0, 60)}${content.length > 60 ? '...' : ''}"`);
|
||
|
|
console.log('\n Knowledge successfully ingested!\n');
|
||
|
|
} catch (error) {
|
||
|
|
console.error(` Error: ${error instanceof Error ? error.message : 'Unknown error'}\n`);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'recall': {
|
||
|
|
const query = args.join(' ');
|
||
|
|
if (!query) {
|
||
|
|
console.log('\n Usage: engram recall "<query>"');
|
||
|
|
console.log('\n Search your memories.');
|
||
|
|
console.log('\n Examples:');
|
||
|
|
console.log(' engram recall "rate limit"');
|
||
|
|
console.log(' engram recall "meeting John"\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`\n Searching memories for: "${query}"\n`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = db.searchNodes(query, { limit: 10 });
|
||
|
|
|
||
|
|
if (result.items.length === 0) {
|
||
|
|
console.log(' No memories found matching your query.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(` Found ${result.total} memories (showing ${result.items.length}):\n`);
|
||
|
|
|
||
|
|
for (const node of result.items) {
|
||
|
|
const preview = node.content.slice(0, 80).replace(/\n/g, ' ');
|
||
|
|
const daysSince = Math.floor((Date.now() - node.lastAccessedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||
|
|
console.log(` [${node.id.slice(0, 8)}] ${preview}${node.content.length > 80 ? '...' : ''}`);
|
||
|
|
console.log(` Retention: ${(node.retentionStrength * 100).toFixed(1)}% | Last accessed: ${daysSince}d ago`);
|
||
|
|
console.log();
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error(` Error: ${error instanceof Error ? error.message : 'Unknown error'}\n`);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'eat': {
|
||
|
|
const source = args[0];
|
||
|
|
if (!source) {
|
||
|
|
console.log('\n Usage: engram eat <url|path>');
|
||
|
|
console.log('\n Examples:');
|
||
|
|
console.log(' engram eat https://docs.rs/tauri/latest/');
|
||
|
|
console.log(' engram eat ./README.md');
|
||
|
|
console.log(' engram eat ~/Documents/notes.txt');
|
||
|
|
console.log('\n The Man Page Absorber chunks content intelligently and');
|
||
|
|
console.log(' creates interconnected knowledge nodes for retrieval.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
await eatContent(source, db);
|
||
|
|
} catch (error) {
|
||
|
|
console.error(`\n Error ingesting content: ${error instanceof Error ? error.message : 'Unknown error'}\n`);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'context': {
|
||
|
|
console.log('\n Ghost in the Shell - Current Context\n');
|
||
|
|
const context = captureContext();
|
||
|
|
|
||
|
|
if (context.activeWindow) {
|
||
|
|
console.log(` Active App: ${context.activeWindow.app}`);
|
||
|
|
console.log(` Window Title: ${context.activeWindow.title}`);
|
||
|
|
} else {
|
||
|
|
console.log(' Active Window: (unable to detect)');
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(` Working Dir: ${context.workingDirectory}`);
|
||
|
|
|
||
|
|
if (context.gitBranch) {
|
||
|
|
console.log(` Git Branch: ${context.gitBranch}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (context.recentFiles.length > 0) {
|
||
|
|
console.log('\n Recent Files (last hour):');
|
||
|
|
for (const file of context.recentFiles.slice(0, 5)) {
|
||
|
|
console.log(` - ${file}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (context.clipboard) {
|
||
|
|
console.log('\n Clipboard:');
|
||
|
|
const preview = context.clipboard.slice(0, 200);
|
||
|
|
console.log(` "${preview}${context.clipboard.length > 200 ? '...' : ''}"`);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('\n Injection String:');
|
||
|
|
console.log(` ${formatContextForInjection(context)}`);
|
||
|
|
console.log();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'watch': {
|
||
|
|
console.log('\n Starting Ghost in the Shell context watcher...');
|
||
|
|
console.log(' Press Ctrl+C to stop.\n');
|
||
|
|
|
||
|
|
startContextWatcher(5000);
|
||
|
|
|
||
|
|
// Keep running until interrupted
|
||
|
|
process.on('SIGINT', () => {
|
||
|
|
console.log('\n Stopping context watcher...');
|
||
|
|
process.exit(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Keep the process alive
|
||
|
|
await new Promise(() => {}); // Never resolves
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'dream': {
|
||
|
|
console.log('\n REM Cycle - Discovering Hidden Connections\n');
|
||
|
|
|
||
|
|
// Preview first
|
||
|
|
console.log(' Analyzing knowledge graph...');
|
||
|
|
const preview = await previewREMCycle(db);
|
||
|
|
|
||
|
|
if (preview.connectionsDiscovered === 0) {
|
||
|
|
console.log(' No new connections discovered.');
|
||
|
|
console.log(' Your knowledge graph is well-connected or needs more nodes.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(` Found ${preview.connectionsDiscovered} potential connections!\n`);
|
||
|
|
|
||
|
|
// Show previews
|
||
|
|
console.log(' Discoveries:');
|
||
|
|
for (const d of preview.discoveries.slice(0, 10)) {
|
||
|
|
console.log(` "${d.nodeA}..."`);
|
||
|
|
console.log(` <-> "${d.nodeB}..."`);
|
||
|
|
console.log(` Reason: ${d.reason}\n`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (preview.discoveries.length > 10) {
|
||
|
|
console.log(` ... and ${preview.discoveries.length - 10} more\n`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Actually create the connections
|
||
|
|
if (args[0] !== '--dry-run') {
|
||
|
|
console.log(' Creating connections...');
|
||
|
|
const result = await runREMCycle(db);
|
||
|
|
console.log(` Created ${result.connectionsCreated} new edges in ${result.duration}ms\n`);
|
||
|
|
} else {
|
||
|
|
console.log(' (Dry run - no connections created. Remove --dry-run to create them)\n');
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'rem': {
|
||
|
|
// Alias for dream
|
||
|
|
console.log('\n Starting REM Cycle (alias for "dream")...\n');
|
||
|
|
const result = await runREMCycle(db);
|
||
|
|
|
||
|
|
console.log(` Analyzed: ${result.nodesAnalyzed} nodes`);
|
||
|
|
console.log(` Discovered: ${result.connectionsDiscovered} connections`);
|
||
|
|
console.log(` Created: ${result.connectionsCreated} edges`);
|
||
|
|
console.log(` Duration: ${result.duration}ms\n`);
|
||
|
|
|
||
|
|
if (result.discoveries.length > 0) {
|
||
|
|
console.log(' New connections:');
|
||
|
|
for (const d of result.discoveries.slice(0, 5)) {
|
||
|
|
console.log(` - ${d.reason}`);
|
||
|
|
}
|
||
|
|
if (result.discoveries.length > 5) {
|
||
|
|
console.log(` ... and ${result.discoveries.length - 5} more`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
console.log();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ====================================================================
|
||
|
|
// SHADOW SELF - Unsolved Problems Queue
|
||
|
|
// ====================================================================
|
||
|
|
|
||
|
|
case 'problem': {
|
||
|
|
const description = args.join(' ');
|
||
|
|
if (!description) {
|
||
|
|
console.log('\n Usage: engram problem <description>');
|
||
|
|
console.log('\n Log an unsolved problem for your Shadow to work on.\n');
|
||
|
|
console.log(' Examples:');
|
||
|
|
console.log(' engram problem "How to implement efficient graph traversal"');
|
||
|
|
console.log(' engram problem "Why is the memory leak happening in the worker"');
|
||
|
|
console.log('\n The Shadow Self will periodically revisit these problems');
|
||
|
|
console.log(' when new knowledge might provide insights.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
const shadow = new ShadowSelf();
|
||
|
|
try {
|
||
|
|
const problem = shadow.logProblem(description, {
|
||
|
|
context: formatContextForInjection(captureContext()),
|
||
|
|
priority: 3,
|
||
|
|
});
|
||
|
|
console.log('\n Problem logged to the Shadow Self\n');
|
||
|
|
console.log(` ID: ${problem.id}`);
|
||
|
|
console.log(` Description: ${problem.description.slice(0, 60)}${problem.description.length > 60 ? '...' : ''}`);
|
||
|
|
console.log(` Priority: ${problem.priority}`);
|
||
|
|
console.log(` Status: ${problem.status}`);
|
||
|
|
console.log('\n Your Shadow will work on this while you rest.\n');
|
||
|
|
} finally {
|
||
|
|
shadow.close();
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'problems': {
|
||
|
|
const shadow = new ShadowSelf();
|
||
|
|
try {
|
||
|
|
const problems = shadow.getOpenProblems();
|
||
|
|
const stats = shadow.getStats();
|
||
|
|
|
||
|
|
console.log('\n Shadow Self - Unsolved Problems Queue\n');
|
||
|
|
console.log(` Total: ${stats.total} | Open: ${stats.open} | Investigating: ${stats.investigating} | Solved: ${stats.solved}\n`);
|
||
|
|
|
||
|
|
if (problems.length === 0) {
|
||
|
|
console.log(' No open problems. Your mind is at peace.\n');
|
||
|
|
console.log(' Log a problem with: engram problem "<description>"\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const p of problems) {
|
||
|
|
const priority = '!'.repeat(p.priority);
|
||
|
|
const daysSince = Math.floor((Date.now() - p.createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
||
|
|
console.log(` [${p.id.slice(0, 8)}] ${priority.padEnd(5)} ${p.description.slice(0, 50)}${p.description.length > 50 ? '...' : ''}`);
|
||
|
|
console.log(` Status: ${p.status} | Attempts: ${p.attempts} | Age: ${daysSince}d`);
|
||
|
|
|
||
|
|
// Show any insights
|
||
|
|
const insights = shadow.getInsights(p.id);
|
||
|
|
if (insights.length > 0) {
|
||
|
|
console.log(` Latest insight: "${insights[0]?.insight.slice(0, 40)}..."`);
|
||
|
|
}
|
||
|
|
console.log();
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
shadow.close();
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'solve': {
|
||
|
|
const problemId = args[0];
|
||
|
|
const solution = args.slice(1).join(' ');
|
||
|
|
|
||
|
|
if (!problemId) {
|
||
|
|
console.log('\n Usage: engram solve <problem-id> <solution>');
|
||
|
|
console.log('\n Mark a problem as solved with the solution.\n');
|
||
|
|
console.log(' Example:');
|
||
|
|
console.log(' engram solve abc123 "Used memoization to optimize the traversal"');
|
||
|
|
console.log('\n Use "engram problems" to see problem IDs.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
const shadow = new ShadowSelf();
|
||
|
|
try {
|
||
|
|
// Find the problem (match on prefix)
|
||
|
|
const problems = shadow.getOpenProblems();
|
||
|
|
const match = problems.find(p => p.id.startsWith(problemId));
|
||
|
|
|
||
|
|
if (!match) {
|
||
|
|
console.log(`\n Problem not found: ${problemId}`);
|
||
|
|
console.log(' Use "engram problems" to see open problems.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
shadow.markSolved(match.id, solution || 'Solved (no details provided)');
|
||
|
|
console.log('\n Problem marked as SOLVED\n');
|
||
|
|
console.log(` Problem: ${match.description.slice(0, 50)}...`);
|
||
|
|
console.log(` Solution: ${solution || '(no details)'}`);
|
||
|
|
console.log(` Attempts: ${match.attempts}`);
|
||
|
|
console.log('\n The Shadow rejoices.\n');
|
||
|
|
} finally {
|
||
|
|
shadow.close();
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'shadow': {
|
||
|
|
console.log('\n Shadow Self - Running Background Analysis\n');
|
||
|
|
|
||
|
|
const shadow = new ShadowSelf();
|
||
|
|
try {
|
||
|
|
const stats = shadow.getStats();
|
||
|
|
console.log(` Problems: ${stats.open} open, ${stats.investigating} investigating, ${stats.solved} solved`);
|
||
|
|
console.log(` Total insights generated: ${stats.totalInsights}\n`);
|
||
|
|
|
||
|
|
if (stats.open === 0 && stats.investigating === 0) {
|
||
|
|
console.log(' No problems to work on. The Shadow rests.\n');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(' Running shadow cycle...');
|
||
|
|
const result = runShadowCycle(shadow, db);
|
||
|
|
|
||
|
|
console.log(` Analyzed: ${result.problemsAnalyzed} problems`);
|
||
|
|
console.log(` New insights: ${result.insightsGenerated}\n`);
|
||
|
|
|
||
|
|
if (result.insights.length > 0) {
|
||
|
|
console.log(' Discoveries:');
|
||
|
|
for (const i of result.insights) {
|
||
|
|
console.log(` Problem: "${i.problem}..."`);
|
||
|
|
console.log(` Insight: ${i.insight}\n`);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
console.log(' No new insights yet. The Shadow continues to watch.\n');
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
shadow.close();
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'help':
|
||
|
|
default:
|
||
|
|
console.log(`
|
||
|
|
Engram CLI - Git Blame for AI Thoughts
|
||
|
|
|
||
|
|
Usage: engram <command> [options]
|
||
|
|
|
||
|
|
Core Commands:
|
||
|
|
ingest <content> Store knowledge directly
|
||
|
|
recall <query> Search memories
|
||
|
|
review Review memories due for reinforcement
|
||
|
|
stats [detailed] Show memory statistics
|
||
|
|
|
||
|
|
Memory Processing:
|
||
|
|
dream Run REM cycle (connection discovery)
|
||
|
|
consolidate Run sleep consolidation (alias: sleep)
|
||
|
|
decay Apply memory decay
|
||
|
|
|
||
|
|
Embeddings:
|
||
|
|
embeddings status Check embedding service availability
|
||
|
|
embeddings generate Generate embeddings for all nodes
|
||
|
|
embeddings search Semantic similarity search
|
||
|
|
|
||
|
|
Configuration:
|
||
|
|
config show Display current configuration
|
||
|
|
config set <k> <v> Update a configuration value
|
||
|
|
config reset Reset to default configuration
|
||
|
|
test Run self-tests
|
||
|
|
|
||
|
|
Ghost in the Shell:
|
||
|
|
context Show current system context
|
||
|
|
watch Start context watcher daemon
|
||
|
|
eat <url|path> Ingest docs/content
|
||
|
|
|
||
|
|
Shadow Self (Unsolved Problems):
|
||
|
|
problem <desc> Log a new unsolved problem
|
||
|
|
problems List all open problems
|
||
|
|
solve <id> <sol> Mark a problem as solved
|
||
|
|
shadow Run shadow cycle for insights
|
||
|
|
|
||
|
|
Maintenance:
|
||
|
|
backup Create database backup
|
||
|
|
backups List available backups
|
||
|
|
restore <path> Restore from backup
|
||
|
|
optimize Optimize database
|
||
|
|
health Detailed health check
|
||
|
|
people List people in your network
|
||
|
|
|
||
|
|
Examples:
|
||
|
|
engram ingest "API rate limit is 100 req/min"
|
||
|
|
engram recall "rate limit"
|
||
|
|
engram stats detailed
|
||
|
|
engram embeddings search "authentication"
|
||
|
|
engram config set logging.level debug
|
||
|
|
engram eat https://docs.example.com/api
|
||
|
|
|
||
|
|
The Engram MCP server runs automatically when connected to Claude.
|
||
|
|
Your brain gets smarter while you sleep.
|
||
|
|
`);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
db.close();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getStatusEmoji(status: string): string {
|
||
|
|
switch (status) {
|
||
|
|
case 'healthy':
|
||
|
|
return '(healthy)';
|
||
|
|
case 'warning':
|
||
|
|
return '(warning)';
|
||
|
|
case 'critical':
|
||
|
|
return '(CRITICAL)';
|
||
|
|
default:
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((error) => {
|
||
|
|
console.error('Error:', error instanceof Error ? error.message : error);
|
||
|
|
process.exit(1);
|
||
|
|
});
|