feat: massive Ruflo-inspired upgrade — plugin system, multi-agent swarm, hooks, enhanced memory
New systems (src/plugins/): - Plugin.js: lifecycle hooks (onLoad, onUnload, onConfigChange) + BasePlugin - PluginManager.js: fault-isolated extension point dispatch with metrics - PluginLoader.js: dependency-resolving batch loader with health checks - ExtensionPoints.js: 16 standard extension point names New systems (src/bot/): - hooks.js: HookManager with pre/post tool, pre/post AI, session lifecycle - memory-backend.js: JSONBackend (typed entries + LRU) + InMemoryBackend (ephemeral with TTL) New systems (src/agents/): - Agent.js: typed agents with capabilities, status tracking - Task.js: DAG-compatible tasks with priorities, dependencies, rollback - SwarmCoordinator.js: multi-agent orchestration (simple/hierarchical/swarm topologies) - agents/index.js: 9 agent roles + AgentOrchestrator Bot integration (src/bot/index.js): - 6 new Ruflo-inspired tools: swarm_spawn, swarm_execute, swarm_distribute, swarm_state, swarm_terminate - Plugin system, hook system, swarm initialized in initBot - Pre/post tool hooks wired into tool execution - Ephemeral + persistent memory backends - Agent orchestrator with 9 specialized agent types - Graceful shutdown: all systems cleanup, conversation flush, pidfile release - Return object exposes pluginManager, swarm, hookManager, memBackend, agentOrchestrator, getState This brings Ruflo's multi-agent architecture, plugin extensibility, hook-based lifecycle, and typed memory to zCode.
This commit is contained in:
291
src/bot/memory-backend.js
Normal file
291
src/bot/memory-backend.js
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 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() {
|
||||
if (this._saveTimer) {
|
||||
clearTimeout(this._saveTimer);
|
||||
this._saveTimer = null;
|
||||
}
|
||||
if (!this._dirty) return;
|
||||
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;
|
||||
Reference in New Issue
Block a user