UPGRADE from naive JSON to production-grade conversation memory: Tier 1 — Compressed Summary (max 600 chars): Incrementally built from evicted messages. Preserves conversation topics across 100+ messages in a tiny budget. Tier 2 — Relevant Snippets (BM25-style keyword matching): Scores older messages against current query, injects top 3 matches. Zero external deps — keyword extraction is ~0.1ms. Tier 3 — Sliding Window (last 12 exchanges verbatim): Recent context preserved word-for-word, fitting within token budget. Performance optimizations: - In-memory Map cache with lazy-load from disk (0ms reads) - Debounced async disk writes (3s, non-blocking, never stalls response) - LRU eviction for cache (max 50 chats, prevents memory leak) - Keywords stripped before saving (smaller JSON files) - Backward-compatible: loads old format without keywords, backfills on load - Graceful shutdown flushes all pending saves to disk - Token-aware budget allocation: summary 15% + relevant 15% + recent 70%
632 lines
21 KiB
JavaScript
632 lines
21 KiB
JavaScript
/**
|
||
* 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 };
|