From c63a1c03ae3aab15e02c9a5f632a814485000ec1 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 5 May 2026 14:39:33 +0000 Subject: [PATCH] feat: persistent self-learning memory + curiosity engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New memory.js: JSON-backed MemoryStore with 5 categories (lesson, pattern, preference, discovery, gotcha) - Memory injected into system prompt — bot sees past learnings every session - Curiosity engine: auto-detects errors/fixes, corrections, successful patterns, new tool discoveries - New commands: /memory (stats), /remember (save), /recall (search), /forget (delete) - Runs AFTER response delivery — zero latency impact - 500 memory cap with smart eviction (keeps gotchas/lessons, evicts old discoveries) - data/ directory gitignored (memory is local to each deployment) --- .gitignore | 1 + src/bot/index.js | 170 ++++++++++++++++++++++++++- src/bot/memory.js | 289 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 454 insertions(+), 6 deletions(-) create mode 100644 src/bot/memory.js diff --git a/.gitignore b/.gitignore index e7238aa9..74a7a94e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules/ .env .zcode.config.json logs/ +data/ *.log .DS_Store diff --git a/src/bot/index.js b/src/bot/index.js index ef361d9b..f2595d12 100644 --- a/src/bot/index.js +++ b/src/bot/index.js @@ -11,6 +11,7 @@ import { isDuplicate, markProcessed } from './deduplication.js'; import { queueRequest, clearQueue, isProcessing } from './request-queue.js'; import { sendFormatted, splitMessage, escapeMarkdown, sendStreamingMessage, StreamConsumer, markdownToHtml } from './message-sender.js'; import { withSelfCorrection } from './self-correction.js'; +import { getMemory } from './memory.js'; function buildSessionKey(chatId, threadId) { return threadId ? `${chatId}:${threadId}` : String(chatId); @@ -18,12 +19,20 @@ function buildSessionKey(chatId, threadId) { function buildSystemPrompt(svc) { const model = svc.config?.api?.models?.default || 'glm-5.1'; + const memory = svc.memory; + const memoryContext = memory ? memory.buildContextSummary() : ''; const lines = [ `You are zCode CLI X — an agentic coding assistant powered by Z.AI (${model}) with Telegram integration.`, '', 'You run 24/7 as a Telegram bot. Answer concisely, helpfully, with code examples when relevant.', 'You are NOT the generic GLM model — you are zCode CLI X, a specialized autonomous coding agent.', '', + '## Self-Learning Capabilities', + 'You have PERSISTENT MEMORY across sessions. You remember lessons, patterns, gotchas, user preferences, and discoveries.', + 'When you learn something useful from an interaction (a fix, a working pattern, a user preference), mention that you are saving it to memory.', + 'When a situation matches a past gotcha or lesson, reference that memory explicitly.', + 'Be CURIOUS — when you discover something interesting (a new API quirk, a useful tool combination, a better approach), proactively save it as a discovery.', + '', '## Available Tools', ]; for (const t of svc.tools) { @@ -40,12 +49,94 @@ function buildSystemPrompt(svc) { lines.push('', `## Infrastructure - RTK (Rust Token Killer) active - Z.AI API via ${svc.config?.api?.baseUrl || 'z.ai'} -- Telegram webhook + WebSocket`, - '', - 'Identify ONLY as zCode CLI X.'); +- Telegram webhook + WebSocket +- Persistent memory (self-learning, ${memory ? memory.memories.length : 0} memories stored)`); + if (memoryContext) { + lines.push('', memoryContext); + } + lines.push('', 'Identify ONLY as zCode CLI X.'); return lines.join('\n'); } +// ─────────────────────────────────────────── +// SELF-LEARNING & CURIOSITY ENGINE +// ─────────────────────────────────────────── + +/** + * Analyze every interaction for self-learnable patterns. + * Runs AFTER the response is delivered — zero latency impact. + * + * Learns from: + * - Errors & fixes → gotcha + * - Successful complex solutions → pattern + * - User corrections → lesson + * - New tool/API discoveries → discovery + */ +async function selfLearn(userMessage, aiResponse, memory) { + if (!aiResponse || aiResponse.length < 50) return; + + const msg = userMessage.toLowerCase(); + const resp = aiResponse.toLowerCase(); + + // 1. Detect error patterns → gotcha + if (resp.includes('error') && resp.includes('fix')) { + const errorMatch = aiResponse.match(/error[:\s]+([^\n]{10,120})/i); + const fixMatch = aiResponse.match(/fix[:\s]+([^\n]{10,120})/i); + if (errorMatch && fixMatch) { + await memory.remember('gotcha', `${errorMatch[1].trim()} → ${fixMatch[1].trim()}`, { + trigger: userMessage.substring(0, 100), + }); + logger.info('🧠 Self-learned gotcha from interaction'); + return; + } + } + + // 2. Detect user corrections → lesson + if (msg.match(/^(wrong|incorrect|not quite|actually|no,|fix|that's wrong|don't)/)) { + await memory.remember('lesson', `Correction on "${userMessage.substring(0, 80)}": ${aiResponse.substring(0, 150)}`, { + trigger: userMessage.substring(0, 100), + }); + logger.info('🧠 Self-learned lesson from correction'); + return; + } + + // 3. Detect successful complex solution → pattern + if (resp.includes('✅') && aiResponse.length > 300) { + // Check if this was a non-trivial interaction + if (msg.length > 20 && !msg.match(/^(hi|hello|hey|thanks|ok)/)) { + const existing = memory.recall({ category: 'pattern', query: msg.substring(0, 30), limit: 1 }); + if (existing.length === 0) { + await memory.remember('pattern', `Solution for "${userMessage.substring(0, 60)}": produced ${aiResponse.length} chars with tool usage`, { + trigger: userMessage.substring(0, 100), + }); + logger.info('🧠 Self-learned pattern from successful interaction'); + } + } + return; + } + + // 4. Curiosity: detect new discoveries + // - First-time tool usage + if (resp.includes('bash') && resp.includes('success') && !memory.recall({ query: 'bash tool', limit: 1 }).length) { + await memory.remember('discovery', `Bash tool works for shell command execution on this server`); + logger.info('🧠 Curiosity: discovered bash tool capability'); + return; + } + + // - First-time web search + if (resp.includes('search') && resp.includes('result') && !memory.recall({ query: 'web search', limit: 1 }).length) { + await memory.remember('discovery', `Web search tool is functional and returns results`); + logger.info('🧠 Curiosity: discovered web search capability'); + return; + } + + // - First-time git operation + if (resp.includes('git') && resp.includes('commit') && !memory.recall({ query: 'git operation', limit: 1 }).length) { + await memory.remember('discovery', `Git tool is functional for version control operations`); + logger.info('🧠 Curiosity: discovered git capability'); + } +} + // ─────────────────────────────────────────── // MAIN — called from zcode.js // ─────────────────────────────────────────── @@ -62,8 +153,12 @@ export async function initBot(config, api, tools, skills, agents) { const rtk = getRTK(); await rtk.init(); + // ── Persistent memory (self-learning) ── + const memory = getMemory(); + await memory.init(); + // ── Service registry ── - const svc = { config, api, tools: tools || [], skills: skills || [], agents: agents || [], rtk, + const svc = { config, api, tools: tools || [], skills: skills || [], agents: agents || [], rtk, memory, toolMap: new Map((tools || []).map(t => [t.name, t])), }; @@ -338,7 +433,9 @@ export async function initBot(config, api, tools, skills, agents) { { command: 'stats', description: '📊 System & RTK stats' }, { command: 'voice', description: '🎤 Voice I/O info' }, { command: 'mcp', description: '🔌 MCP info' }, - { command: 'memory', description: '🧠 Memory info' }, + { command: 'memory', description: '🧠 Persistent memory stats' }, + { command: 'remember', description: '📝 Save to memory' }, + { command: 'recall', description: '🔍 Search memory' }, { command: 'cron', description: '⏰ Scheduled tasks' }, { command: 'bash', description: '💻 Execute shell command' }, { command: 'web', description: '🔍 Search the web' }, @@ -467,7 +564,65 @@ export async function initBot(config, api, tools, skills, agents) { }); bot.command('memory', async (ctx) => { - await ctx.reply('🧠 *Memory:* Session memory with 4 types (user, feedback, project, reference) — auto-scanned into context.'); + const stats = memory.getStats(); + const recent = memory.recall({ limit: 5 }); + const lines = [ + `🧠 *Persistent Memory* — ${stats.total} memories stored`, + '', + `📅 From ${stats.oldest || 'today'} to ${stats.newest || 'now'}`, + '', + '*Categories:*', + ]; + for (const [cat, count] of Object.entries(stats.categories)) { + const icon = { lesson: '📖', pattern: '🔧', preference: '👤', discovery: '💡', gotcha: '⚠️' }[cat] || '📝'; + lines.push(` ${icon} ${cat}: ${count}`); + } + if (recent.length > 0) { + lines.push('', '*Recent:*'); + for (const m of recent.slice(0, 5)) { + lines.push(` • [${m.category}] ${m.content.substring(0, 60)}`); + } + } + lines.push('', '_Use /remember to save, /recall to search, /forget to delete_'); + await sendStreamingMessage(ctx, lines.join('\n')); + }); + + bot.command('remember', async (ctx) => { + const text = ctx.match?.trim(); + if (!text) return ctx.reply('Usage: /remember \n\nCategories: lesson, pattern, preference, discovery, gotcha\n\nExamples:\n/remember User prefers TypeScript over JavaScript\n/remember gotcha: Z.AI SSE sends empty data lines between chunks'); + // Auto-detect category from keywords + let category = 'preference'; + if (/^(lesson|pattern|preference|discovery|gotcha)[:\s]/i.test(text)) { + const match = text.match(/^(lesson|pattern|preference|discovery|gotcha)[:\s]\s*(.*)/i); + if (match) { + category = match[1].toLowerCase(); + await memory.remember(category, match[2]); + } + } else { + await memory.remember(category, text); + } + await ctx.reply(`🧠 Saved to memory [${category}]. I will remember this across sessions.`); + }); + + bot.command('recall', async (ctx) => { + const query = ctx.match?.trim(); + if (!query) return ctx.reply('Usage: /recall \n\nExample: /recall ZAI API timeout'); + const results = memory.recall({ query, limit: 10 }); + if (results.length === 0) return ctx.reply('🔍 No memories match that query.'); + const lines = [`🔍 *Recall* (${results.length} results):`, '']; + for (const m of results) { + const date = new Date(m.created).toLocaleDateString(); + lines.push(`[${m.category}] (${date}) ${m.content.substring(0, 80)}`); + if (m.meta?.resolution) lines.push(` → ${m.meta.resolution.substring(0, 60)}`); + } + await sendStreamingMessage(ctx, lines.join('\n')); + }); + + bot.command('forget', async (ctx) => { + const id = ctx.match?.trim(); + if (!id) return ctx.reply('Usage: /forget \n\nFind IDs with /recall or /memory'); + const ok = await memory.forget(id); + await ctx.reply(ok ? '🗑 Memory deleted.' : '❌ Memory not found.'); }); bot.command('cron', async (ctx) => { @@ -543,6 +698,9 @@ export async function initBot(config, api, tools, skills, agents) { if (!consumer.alreadySent && result) { await sendFormatted(ctx, result); } + + // ── Self-learning: extract patterns from this interaction ── + await selfLearn(text, result, memory); }); }); diff --git a/src/bot/memory.js b/src/bot/memory.js new file mode 100644 index 00000000..7a5412a9 --- /dev/null +++ b/src/bot/memory.js @@ -0,0 +1,289 @@ +/** + * 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 './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); + } +} + +// Singleton +let _instance = null; +export function getMemory() { + if (!_instance) _instance = new MemoryStore(); + return _instance; +} +export { MemoryStore };