feat: persistent self-learning memory + curiosity engine

- 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)
This commit is contained in:
admin
2026-05-05 14:39:33 +00:00
Unverified
parent d7107e162f
commit c63a1c03ae
3 changed files with 454 additions and 6 deletions

289
src/bot/memory.js Normal file
View File

@@ -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 };