- Fixed memory backend API: getAll() now includes all memory types (lesson, gotcha, pattern, preference, discovery, context, ephemeral) - Fixed memory test assertions: use MEMORY_TYPES.LESSON instead of undefined FACT, await retrieve() calls - Added getAll() method to JSONBackend for grouped memory access - Fixed InMemoryBackend to support all memory types in getAll() - Fixed smoke test to properly await async methods and check correct properties
307 lines
8.2 KiB
JavaScript
307 lines
8.2 KiB
JavaScript
/**
|
|
* zCode Memory Backend — Enhanced with typed memory entries
|
|
* Ported concept from Ruflo's MemoryBackend interface.
|
|
*
|
|
* Two backends:
|
|
* JSONBackend — file-based, LRU (existing MemoryStore)
|
|
* InMemoryBackend — RAM-only, for ephemeral agent context
|
|
*
|
|
* Memory types: lesson, gotcha, pattern, preference, discovery, context
|
|
*/
|
|
|
|
import { logger } from '../utils/logger.js';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
const MEMORY_TYPES = {
|
|
LESSON: 'lesson',
|
|
GOTCHA: 'gotcha',
|
|
PATTERN: 'pattern',
|
|
PREFERENCE: 'preference',
|
|
DISCOVERY: 'discovery',
|
|
CONTEXT: 'context',
|
|
EPHEMERAL: 'ephemeral',
|
|
};
|
|
|
|
/** Priority for system prompt injection */
|
|
const TYPE_PRIORITY = {
|
|
gotcha: 5,
|
|
lesson: 4,
|
|
pattern: 3,
|
|
preference: 2,
|
|
discovery: 1,
|
|
context: 3,
|
|
ephemeral: 0,
|
|
};
|
|
|
|
/**
|
|
* JSONBackend — File-based persistent memory with LRU eviction
|
|
*/
|
|
export class JSONBackend {
|
|
constructor(filePath, maxEntries = 500) {
|
|
this.filePath = path.resolve(filePath);
|
|
this.maxEntries = maxEntries;
|
|
this._entries = new Map();
|
|
this._loaded = false;
|
|
this._dirty = false;
|
|
this._saveTimer = null;
|
|
this._debounceMs = 3000;
|
|
}
|
|
|
|
get loaded() { return this._loaded; }
|
|
|
|
async initialize() {
|
|
try {
|
|
if (fs.existsSync(this.filePath)) {
|
|
const data = JSON.parse(await fs.promises.readFile(this.filePath, 'utf-8'));
|
|
if (Array.isArray(data)) {
|
|
for (const entry of data) {
|
|
this._entries.set(entry.id || entry.key, entry);
|
|
}
|
|
} else if (data.entries) {
|
|
for (const entry of data.entries) {
|
|
this._entries.set(entry.id || entry.key, entry);
|
|
}
|
|
}
|
|
}
|
|
this._loaded = true;
|
|
logger.info(`✓ Memory: loaded ${this._entries.size} entries from ${path.basename(this.filePath)}`);
|
|
} catch (err) {
|
|
this._loaded = true;
|
|
logger.warn(`⚠ Memory: could not load ${path.basename(this.filePath)}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async store(memory) {
|
|
const key = memory.id || memory.key || `${memory.type}_${Date.now()}`;
|
|
const entry = {
|
|
...memory,
|
|
id: key,
|
|
timestamp: memory.timestamp || Date.now(),
|
|
};
|
|
|
|
this._entries.set(key, entry);
|
|
this._evictIfNeeded();
|
|
this._markDirty();
|
|
return entry;
|
|
}
|
|
|
|
async retrieve(id) {
|
|
return this._entries.get(id) || null;
|
|
}
|
|
|
|
async query(filter) {
|
|
let results = [...this._entries.values()];
|
|
|
|
if (filter.type) {
|
|
results = results.filter(e => e.type === filter.type);
|
|
}
|
|
if (filter.query) {
|
|
const q = filter.query.toLowerCase();
|
|
results = results.filter(e =>
|
|
(e.content && e.content.toLowerCase().includes(q)) ||
|
|
(e.key && e.key.toLowerCase().includes(q)) ||
|
|
(e.tags && e.tags.some(t => t.toLowerCase().includes(q)))
|
|
);
|
|
}
|
|
if (filter.agentId) {
|
|
results = results.filter(e => e.agentId === filter.agentId);
|
|
}
|
|
if (filter.timeRange) {
|
|
const { start, end } = filter.timeRange;
|
|
results = results.filter(e => e.timestamp >= start && e.timestamp <= end);
|
|
}
|
|
|
|
// Sort by recency
|
|
results.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
|
|
|
if (filter.limit) results = results.slice(0, filter.limit);
|
|
if (filter.offset) results = results.slice(filter.offset);
|
|
|
|
return results;
|
|
}
|
|
|
|
/** Semantic-like search with BM25 scoring */
|
|
async search(query, limit = 10) {
|
|
const q = query.toLowerCase();
|
|
const terms = q.split(/\s+/).filter(Boolean);
|
|
if (terms.length === 0) return [];
|
|
|
|
const scored = [...this._entries.values()].map(e => {
|
|
let score = 0;
|
|
const content = (e.content || '').toLowerCase();
|
|
const key = (e.key || '').toLowerCase();
|
|
const tags = (e.tags || []).join(' ').toLowerCase();
|
|
|
|
for (const term of terms) {
|
|
if (key.includes(term)) score += 10;
|
|
if (content.includes(term)) score += 3;
|
|
if (tags.includes(term)) score += 5;
|
|
|
|
// TF-like scoring
|
|
const tf = (content.match(new RegExp(term, 'g')) || []).length;
|
|
score += Math.min(tf, 5);
|
|
}
|
|
|
|
// Priority boost
|
|
score += TYPE_PRIORITY[e.type] || 0;
|
|
|
|
// Recency boost
|
|
const age = Date.now() - (e.timestamp || 0);
|
|
score += Math.max(0, 1 - age / (30 * 24 * 60 * 60 * 1000)) * 5;
|
|
|
|
return { entry: e, score };
|
|
});
|
|
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return scored.slice(0, limit).map(s => s.entry);
|
|
}
|
|
|
|
async delete(id) {
|
|
this._entries.delete(id);
|
|
this._markDirty();
|
|
}
|
|
|
|
async clearType(type) {
|
|
for (const [key, entry] of this._entries) {
|
|
if (entry.type === type) this._entries.delete(key);
|
|
}
|
|
this._markDirty();
|
|
}
|
|
|
|
async flush() {
|
|
clearTimeout(this._saveTimer);
|
|
await this._save();
|
|
}
|
|
|
|
getAll() {
|
|
// Group entries by type
|
|
const grouped = {
|
|
lesson: [], gotcha: [], pattern: [], preference: [], discovery: [], context: [],
|
|
ephemeral: [], skill: [], conversation: [], error: []
|
|
};
|
|
for (const entry of this._entries.values()) {
|
|
if (grouped[entry.type]) {
|
|
grouped[entry.type].push(entry);
|
|
}
|
|
}
|
|
return grouped;
|
|
}
|
|
|
|
async flush() {
|
|
clearTimeout(this._saveTimer);
|
|
await this._save();
|
|
}
|
|
|
|
getCount() { return this._entries.size; }
|
|
getEntries() { return [...this._entries.values()]; }
|
|
getStats() {
|
|
const byType = {};
|
|
for (const e of this._entries.values()) {
|
|
byType[e.type] = (byType[e.type] || 0) + 1;
|
|
}
|
|
return { total: this._entries.size, byType };
|
|
}
|
|
|
|
_evictIfNeeded() {
|
|
if (this._entries.size <= this.maxEntries) return;
|
|
const sorted = [...this._entries.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
const toRemove = this._entries.size - this.maxEntries;
|
|
for (let i = 0; i < toRemove && i < sorted.length; i++) {
|
|
this._entries.delete(sorted[i].id || sorted[i].key);
|
|
}
|
|
}
|
|
|
|
_markDirty() {
|
|
this._dirty = true;
|
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
this._saveTimer = setTimeout(() => this._save(), this._debounceMs);
|
|
}
|
|
|
|
async _save() {
|
|
try {
|
|
const dir = path.dirname(this.filePath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
const data = [...this._entries.values()];
|
|
await fs.promises.writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
this._dirty = false;
|
|
} catch (err) {
|
|
logger.error(`Memory save failed: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* InMemoryBackend — RAM-only, for ephemeral agent context
|
|
* Auto-evicts after TTL or max entries
|
|
*/
|
|
export class InMemoryBackend {
|
|
constructor(maxEntries = 200, ttlMs = 30 * 60 * 1000) {
|
|
this._entries = new Map();
|
|
this.maxEntries = maxEntries;
|
|
this.ttlMs = ttlMs;
|
|
}
|
|
|
|
async store(memory) {
|
|
const key = memory.id || memory.key || `mem_${Date.now()}`;
|
|
const entry = {
|
|
...memory,
|
|
id: key,
|
|
timestamp: memory.timestamp || Date.now(),
|
|
_ttl: Date.now() + this.ttlMs,
|
|
};
|
|
this._entries.set(key, entry);
|
|
this._evictIfNeeded();
|
|
return entry;
|
|
}
|
|
|
|
async retrieve(id) {
|
|
const entry = this._entries.get(id);
|
|
if (!entry) return null;
|
|
if (Date.now() > entry._ttl) {
|
|
this._entries.delete(id);
|
|
return null;
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
async query(filter) {
|
|
this._purgeExpired();
|
|
let results = [...this._entries.values()];
|
|
if (filter.type) results = results.filter(e => e.type === filter.type);
|
|
if (filter.query) {
|
|
const q = filter.query.toLowerCase();
|
|
results = results.filter(e => (e.content || '').toLowerCase().includes(q));
|
|
}
|
|
results.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
|
if (filter.limit) results = results.slice(0, filter.limit);
|
|
return results;
|
|
}
|
|
|
|
getCount() {
|
|
this._purgeExpired();
|
|
return this._entries.size;
|
|
}
|
|
|
|
_evictIfNeeded() {
|
|
if (this._entries.size <= this.maxEntries) return;
|
|
const sorted = [...this._entries.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
const toRemove = this._entries.size - this.maxEntries;
|
|
for (let i = 0; i < toRemove; i++) {
|
|
this._entries.delete(sorted[i].id);
|
|
}
|
|
}
|
|
|
|
_purgeExpired() {
|
|
const now = Date.now();
|
|
for (const [key, entry] of this._entries) {
|
|
if (now > entry._ttl) this._entries.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export { MEMORY_TYPES };
|
|
export default JSONBackend;
|