/** * Persistent memory & self-learning system for zCode CLI X. * * Adapted from Hermes Agent's memory tool — stores lessons, preferences, * and discoveries across sessions in a JSON file. * * Memory categories: * - lesson: Things learned from mistakes/corrections * - pattern: Coding patterns that work well * - preference: User preferences and style choices * - discovery: Facts about the environment, APIs, tools * - gotcha: Bugs/pitfalls to avoid (trigger + resolution) */ import fs from 'fs-extra'; import path from 'path'; import { logger } from '../utils/logger.js'; const MEMORY_DIR = path.join(process.cwd(), 'data'); const MEMORY_FILE = path.join(MEMORY_DIR, 'memory.json'); const MAX_MEMORIES = 500; const MAX_SUMMARY_LENGTH = 2000; // chars for system prompt injection class MemoryStore { constructor() { this.memories = []; this.loaded = false; } /** * Load memories from disk. Called once at startup. */ async init() { try { await fs.ensureDir(MEMORY_DIR); if (await fs.pathExists(MEMORY_FILE)) { const data = await fs.readJson(MEMORY_FILE); this.memories = Array.isArray(data) ? data : []; logger.info(`✓ Memory loaded: ${this.memories.length} memories`); } else { this.memories = []; await this._save(); logger.info('✓ Memory initialized (empty)'); } this.loaded = true; } catch (e) { logger.error('Memory init failed:', e.message); this.memories = []; this.loaded = true; } } /** * Remember something new. * @param {'lesson'|'pattern'|'preference'|'discovery'|'gotcha'} category * @param {string} content - What to remember * @param {object} [meta] - Optional metadata (trigger, resolution, source) */ async remember(category, content, meta = {}) { if (!this.loaded) await this.init(); // Check for duplicates (similar content in same category) const existing = this.memories.find( m => m.category === category && m.content === content ); if (existing) { existing.updated = Date.now(); existing.accessCount = (existing.accessCount || 0) + 1; logger.info(`📝 Memory updated (duplicate): [${category}] ${content.substring(0, 60)}`); await this._save(); return existing; } const memory = { id: this._generateId(), category, content, meta, created: Date.now(), updated: Date.now(), accessCount: 1, }; this.memories.unshift(memory); // Evict oldest if over limit if (this.memories.length > MAX_MEMORIES) { // Keep lessons and gotchas, evict old discoveries first const evictable = this.memories .map((m, i) => ({ ...m, index: i })) .filter(m => m.category === 'discovery' && m.accessCount <= 1) .sort((a, b) => a.created - b.created); if (evictable.length > 0) { this.memories.splice(evictable[0].index, 1); } else { this.memories.pop(); } } logger.info(`📝 Memory saved: [${category}] ${content.substring(0, 60)}`); await this._save(); return memory; } /** * Recall memories matching a query or category. * @param {object} [filter] - { category, query, limit } * @returns {Array} Matching memories */ recall(filter = {}) { if (!this.loaded) return []; let results = [...this.memories]; if (filter.category) { results = results.filter(m => m.category === filter.category); } if (filter.query) { const terms = filter.query.toLowerCase().split(/\s+/); results = results.filter(m => { const text = `${m.content} ${(m.meta?.trigger || '')} ${(m.meta?.resolution || '')}`.toLowerCase(); return terms.some(t => text.includes(t)); }); // Score by match count results.sort((a, b) => { const textA = `${a.content} ${a.meta?.trigger || ''}`.toLowerCase(); const textB = `${b.content} ${b.meta?.trigger || ''}`.toLowerCase(); const scoreA = terms.filter(t => textA.includes(t)).length; const scoreB = terms.filter(t => textB.includes(t)).length; return scoreB - scoreA; }); } // Boost recently accessed results.sort((a, b) => (b.updated || 0) - (a.updated || 0)); const limit = filter.limit || 20; return results.slice(0, limit); } /** * Build a compact summary of all memories for system prompt injection. * Prioritizes: gotchas > lessons > patterns > preferences > discoveries */ buildContextSummary() { if (!this.loaded || this.memories.length === 0) return ''; const priority = ['gotcha', 'lesson', 'pattern', 'preference', 'discovery']; const byCategory = {}; for (const cat of priority) { const items = this.memories .filter(m => m.category === cat) .sort((a, b) => (b.accessCount || 0) - (a.accessCount || 0)) .slice(0, 10); // max 10 per category if (items.length) byCategory[cat] = items; } const lines = ['## Persistent Memory (learned across sessions)', '']; for (const cat of priority) { if (!byCategory[cat]) continue; const label = cat.charAt(0).toUpperCase() + cat.slice(1) + 's'; lines.push(`### ${label}`); for (const m of byCategory[cat]) { let entry = `- ${m.content}`; if (m.meta?.trigger) entry += ` (trigger: ${m.meta.trigger})`; if (m.meta?.resolution) entry += ` → fix: ${m.meta.resolution}`; lines.push(entry); } lines.push(''); } const full = lines.join('\n'); if (full.length > MAX_SUMMARY_LENGTH) { return full.substring(0, MAX_SUMMARY_LENGTH) + '\n...(truncated)'; } return full; } /** * Get stats for /memory command. */ getStats() { if (!this.loaded) return { total: 0, categories: {} }; const categories = {}; for (const m of this.memories) { categories[m.category] = (categories[m.category] || 0) + 1; } return { total: this.memories.length, categories, oldest: this.memories.length ? new Date(this.memories[this.memories.length - 1].created).toISOString().split('T')[0] : null, newest: this.memories.length ? new Date(this.memories[0].created).toISOString().split('T')[0] : null, }; } /** * Self-learn from an interaction. * Called after each AI response to extract learnable patterns. * @param {string} userMessage * @param {string} aiResponse * @param {object} [context] - { error, correction, toolUsed } */ async learnFromInteraction(userMessage, aiResponse, context = {}) { if (!this.loaded) await this.init(); const learned = []; // 1. Detect error → gotcha if (context.error) { learned.push(await this.remember('gotcha', `Error in "${userMessage.substring(0, 50)}": ${context.error.substring(0, 100)}`, { trigger: userMessage.substring(0, 100), resolution: aiResponse.substring(0, 200), })); } // 2. Detect user correction → lesson if (context.correction) { learned.push(await this.remember('lesson', `Correction: ${context.correction.substring(0, 150)}`, { trigger: userMessage.substring(0, 100), })); } // 3. Detect successful tool usage → pattern if (context.toolUsed && !context.error) { // Only save if it's a complex/successful interaction if (aiResponse.includes('✅') || aiResponse.length > 200) { learned.push(await this.remember('pattern', `Successful ${context.toolUsed} for: ${userMessage.substring(0, 80)}`)); } } if (learned.length > 0) { logger.info(`🧠 Self-learned ${learned.length} memories from interaction`); } return learned; } /** * Forget a memory by ID. */ async forget(id) { const idx = this.memories.findIndex(m => m.id === id); if (idx === -1) return false; this.memories.splice(idx, 1); await this._save(); logger.info(`🗑 Memory forgotten: ${id}`); return true; } /** * Clear all memories in a category. */ async clearCategory(category) { const before = this.memories.length; this.memories = this.memories.filter(m => m.category !== category); const removed = before - this.memories.length; await this._save(); logger.info(`🗑 Cleared ${removed} memories in [${category}]`); return removed; } // ── Private ── async _save() { try { await fs.writeJson(MEMORY_FILE, this.memories, { spaces: 2 }); } catch (e) { logger.error('Memory save failed:', e.message); } } _generateId() { return Date.now().toString(36) + Math.random().toString(36).substring(2, 6); } } // ─────────────────────────────────────────── // CONVERSATION HISTORY — tiered, in-memory, cross-session // // Architecture (3-tier context building): // Tier 1: Compressed summary of old conversations (max 600 chars) // Tier 2: Relevant snippets pulled by keyword matching (max 3 × 150 chars) // Tier 3: Recent messages verbatim, sliding window (last 12 exchanges) // // Performance: // - In-memory Map cache with lazy-load from disk (0ms reads) // - Debounced async disk writes (3s, non-blocking — never stalls response) // - LRU eviction for in-memory cache (max 50 chats) // - Keyword extraction at save time (cheap, no LLM call) // - BM25-style relevance scoring against current query // - Backward-compatible: loads old JSON format without keywords // ─────────────────────────────────────────── const HISTORY_DIR = path.join(process.cwd(), 'data'); const CONV_CFG = { MAX_HISTORY: 60, // Max messages stored per chat on disk MAX_RECENT: 12, // Last N exchanges in verbatim window SUMMARY_MAX: 600, // Compressed summary budget (chars) RELEVANT_MAX: 3, // Max relevant older snippets SNIPPET_CHARS: 150, // Per relevant snippet CONTEXT_BUDGET: 6000, // Total token budget for history CHARS_PER_TOKEN: 1.3, // Mixed content estimate SAVE_DEBOUNCE: 3000, // Disk write debounce (ms) CACHE_MAX: 50, // LRU in-memory cache limit }; // Stop words for keyword extraction (no external deps) const STOP_WORDS = new Set([ 'the','a','an','is','are','was','were','be','been','being','have','has','had', 'do','does','did','will','would','could','should','may','might','shall','can', 'need','to','of','in','for','on','with','at','by','from','as','into','through', 'during','before','after','above','below','between','out','off','over','under', 'again','further','then','once','here','there','when','where','why','how','all', 'both','each','few','more','most','other','some','such','no','nor','not','only', 'own','same','so','than','too','very','just','because','but','and','or','if', 'while','about','up','it','its','i','me','my','we','our','you','your','he', 'him','his','she','her','they','them','their','this','that','these','those', 'what','which','who','whom','also','like','get','got','make','made','know', 'think','see','way','thing','things','want','really','much','well','still', 'even','back','now','new','one','two','go','going','come','say','said','tell', 'let','give','use','using','used','many','any','help','write','please','ok', ]); class ConversationStore { constructor() { // In-memory cache: chatKey → { messages: [], summary: '' } // messages[].kw = [top5 keywords] — extracted at save time, used for relevance this.cache = new Map(); this.accessOrder = []; // LRU tracking this.saveTimers = new Map(); // Debounced disk writes this.loaded = false; } async init() { try { await fs.ensureDir(HISTORY_DIR); } catch {} this.loaded = true; logger.info(`✓ Conversation store initialized (3-tier, in-memory, LRU max ${CONV_CFG.CACHE_MAX} chats)`); } // ── Keys & paths ── _key(chatId, threadId) { return `${chatId}:${threadId || 'main'}`; } _filePath(chatKey) { return path.join(HISTORY_DIR, `chat_${chatKey.replace(/[^a-zA-Z0-9_\-]/g, '_')}.json`); } // ── LRU cache ── _touch(chatKey) { const idx = this.accessOrder.indexOf(chatKey); if (idx !== -1) this.accessOrder.splice(idx, 1); this.accessOrder.push(chatKey); // Evict LRU entry if over limit while (this.accessOrder.length > CONV_CFG.CACHE_MAX) { const evict = this.accessOrder.shift(); this._flushSync(evict); // Sync flush on eviction this.cache.delete(evict); } } // ── Lazy disk load (only when chat is first accessed) ── async _ensure(chatKey) { if (this.cache.has(chatKey)) { this._touch(chatKey); return this.cache.get(chatKey); } let data = { messages: [], summary: '' }; try { const fp = this._filePath(chatKey); if (await fs.pathExists(fp)) { const raw = await fs.readJson(fp); // Backward-compatible: old format was plain array, new format is {messages, summary} if (Array.isArray(raw)) { data.messages = raw; } else { data.messages = Array.isArray(raw.messages) ? raw.messages : []; data.summary = raw.summary || ''; } // Backfill keywords for old messages that don't have them for (const msg of data.messages) { if (!msg.kw && msg.content) { msg.kw = this._topKeywords(this._extractKeywords(msg.content), 5); } } } } catch (e) { logger.warn(`History load ${chatKey}: ${e.message}`); } this.cache.set(chatKey, data); this._touch(chatKey); return data; } // ── Debounced async disk write (never blocks the response) ── _scheduleSave(chatKey) { if (this.saveTimers.has(chatKey)) return; const timer = setTimeout(() => { this.saveTimers.delete(chatKey); this._flushSync(chatKey); }, CONV_CFG.SAVE_DEBOUNCE); timer.unref(); // Don't prevent process exit this.saveTimers.set(chatKey, timer); } _flushSync(chatKey) { const data = this.cache.get(chatKey); if (!data) return; // Strip keywords before saving (smaller files, rebuild on load) const stripped = { messages: data.messages.map(({ role, content, ts }) => ({ role, content, ts })), summary: data.summary, }; fs.writeJson(this._filePath(chatKey), stripped, { spaces: 2 }) .catch(e => logger.error(`Save ${chatKey}: ${e.message}`)); } // ── Keyword extraction (zero-dependency, ~0.1ms) ── _extractKeywords(text) { const freq = {}; for (const word of text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/)) { if (word.length > 2 && !STOP_WORDS.has(word)) freq[word] = (freq[word] || 0) + 1; } return freq; } _topKeywords(freqMap, n = 5) { return Object.entries(freqMap).sort((a, b) => b[1] - a[1]).slice(0, n).map(([w]) => w); } // BM25-style relevance: keyword overlap score _score(msgKw, queryFreq) { let score = 0; for (const kw of msgKw) { if (queryFreq[kw]) score += queryFreq[kw]; } return score; } // ── Incremental summary builder ── _updateSummary(data, evicted) { // Extract topic from the evicted exchange const userMsg = evicted.role === 'user' ? evicted.content : null; const topic = userMsg ? userMsg.substring(0, 80).replace(/\n/g, ' ').trim() : `discussed: ${evicted.content.substring(0, 50).replace(/\n/g, ' ').trim()}`; const addition = `• ${topic}`; const newSummary = data.summary ? `${data.summary}\n${addition}` : addition; if (newSummary.length > CONV_CFG.SUMMARY_MAX) { // Keep the tail (most recent topics) data.summary = newSummary.substring(newSummary.length - CONV_CFG.SUMMARY_MAX); // Clean leading partial line const nl = data.summary.indexOf('\n'); if (nl > 0 && nl < 80) data.summary = data.summary.substring(nl + 1); } else { data.summary = newSummary; } } // ── Public API ── /** * Add a message. Extracts keywords, updates summary on eviction, * debounces disk write (non-blocking). */ async add(chatKey, role, content) { if (!this.loaded) await this.init(); const data = await this._ensure(chatKey); data.messages.push({ role, content, ts: Date.now(), kw: this._topKeywords(this._extractKeywords(content), 5), }); // Evict oldest + build incremental summary while (data.messages.length > CONV_CFG.MAX_HISTORY) { this._updateSummary(data, data.messages.shift()); } this._scheduleSave(chatKey); } /** * Build 3-tier context for the API call. * @param {string} chatKey * @param {string} [query] - Current user message for relevance scoring * @returns {Array<{role, content}>} Messages to inject before current user message */ async getContext(chatKey, query = '') { if (!this.loaded) await this.init(); const data = await this._ensure(chatKey); if (data.messages.length === 0 && !data.summary) return []; const parts = []; let budget = CONV_CFG.CONTEXT_BUDGET; const cost = (text) => Math.ceil(text.length / CONV_CFG.CHARS_PER_TOKEN); // ── Tier 1: Compressed summary (max 15% of budget) ── if (data.summary) { const summaryText = `[Earlier in this conversation (summary):\n${data.summary}]`; const summaryCost = cost(summaryText); if (summaryCost < budget * 0.15) { parts.push({ role: 'system', content: summaryText }); budget -= summaryCost; } } // ── Tier 2: Relevant older snippets via keyword matching ── if (query && data.messages.length > CONV_CFG.MAX_RECENT * 2) { const queryFreq = this._extractKeywords(query); const recentStart = Math.max(0, data.messages.length - CONV_CFG.MAX_RECENT * 2); const scored = []; for (let i = 0; i < recentStart; i++) { const msg = data.messages[i]; if (!msg.kw || !msg.kw.length) continue; const s = this._score(msg.kw, queryFreq); if (s > 0) scored.push({ msg, score: s, age: i }); // Lower age = older } // Sort by score desc, take top N scored.sort((a, b) => b.score - a.score); const relevant = scored.slice(0, CONV_CFG.RELEVANT_MAX); if (relevant.length > 0) { const snippets = relevant.map(({ msg }) => { const role = msg.role === 'assistant' ? 'Assistant' : 'User'; const text = msg.content.substring(0, CONV_CFG.SNIPPET_CHARS).replace(/\n/g, ' ').trim(); return `[${role} (earlier): ${text}]`; }).join('\n'); const relCost = cost(snippets); if (relCost < budget * 0.15) { parts.push({ role: 'system', content: `[Related earlier exchange:\n${snippets}]` }); budget -= relCost; } } } // ── Tier 3: Recent messages verbatim (sliding window) ── const recent = data.messages.slice(-CONV_CFG.MAX_RECENT); let hasContent = false; for (const msg of recent) { if (msg.role === 'system') continue; const msgCost = cost(msg.content); if (msgCost > budget && hasContent) break; // Stop when budget exceeded budget -= msgCost; parts.push({ role: msg.role, content: msg.content }); hasContent = true; } return parts; } /** * Get stats for a chat. */ async stats(chatKey) { const data = await this._ensure(chatKey); return { messages: data.messages.length, summaryLength: data.summary.length, cachedChats: this.cache.size, }; } /** * Clear history for a chat. */ async clear(chatKey) { this.cache.delete(chatKey); const idx = this.accessOrder.indexOf(chatKey); if (idx !== -1) this.accessOrder.splice(idx, 1); if (this.saveTimers.has(chatKey)) { clearTimeout(this.saveTimers.get(chatKey)); this.saveTimers.delete(chatKey); } try { const fp = this._filePath(chatKey); if (await fs.pathExists(fp)) await fs.remove(fp); } catch {} logger.info(`🗑 Cleared history for ${chatKey}`); } /** * Flush ALL pending saves to disk. Call on graceful shutdown. */ async flush() { for (const [key, timer] of this.saveTimers) { clearTimeout(timer); this._flushSync(key); } this.saveTimers.clear(); logger.info(`💾 Flushed ${this.cache.size} chat histories to disk`); } } // Singletons let _memoryInstance = null; export function getMemory() { if (!_memoryInstance) _memoryInstance = new MemoryStore(); return _memoryInstance; } let _conversationInstance = null; export function getConversation() { if (!_conversationInstance) _conversationInstance = new ConversationStore(); return _conversationInstance; } export { MemoryStore, ConversationStore };