/** * Agent Memory System - Persistent cross-session memory for ZCLAW agents * * Phase 1 implementation: zustand persist (localStorage) with keyword search. * Designed for easy upgrade to SQLite + FTS5 + vector search in Phase 2. * * Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.1 */ // === Types === export type MemoryType = 'fact' | 'preference' | 'lesson' | 'context' | 'task'; export type MemorySource = 'auto' | 'user' | 'reflection' | 'llm-reflection'; export interface MemoryEntry { id: string; agentId: string; content: string; type: MemoryType; importance: number; // 0-10 source: MemorySource; tags: string[]; createdAt: string; // ISO timestamp lastAccessedAt: string; accessCount: number; conversationId?: string; } export interface MemorySearchOptions { agentId?: string; type?: MemoryType; types?: MemoryType[]; tags?: string[]; limit?: number; minImportance?: number; } export interface MemoryStats { totalEntries: number; byType: Record; byAgent: Record; oldestEntry: string | null; newestEntry: string | null; } // === Memory ID Generator === function generateMemoryId(): string { return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } // === Keyword Search Scoring === function tokenize(text: string): string[] { return text .toLowerCase() .replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf]+/g, ' ') .split(/\s+/) .filter(t => t.length > 0); } function searchScore(entry: MemoryEntry, queryTokens: string[]): number { const contentTokens = tokenize(entry.content); const tagTokens = entry.tags.flatMap(t => tokenize(t)); const allTokens = [...contentTokens, ...tagTokens]; let matched = 0; for (const qt of queryTokens) { if (allTokens.some(t => t.includes(qt) || qt.includes(t))) { matched++; } } if (matched === 0) return 0; const relevance = matched / queryTokens.length; const importanceBoost = entry.importance / 10; const recencyBoost = Math.max(0, 1 - (Date.now() - new Date(entry.lastAccessedAt).getTime()) / (30 * 24 * 60 * 60 * 1000)); // decay over 30 days return relevance * 0.6 + importanceBoost * 0.25 + recencyBoost * 0.15; } // === MemoryManager Implementation === const STORAGE_KEY = 'zclaw-agent-memories'; export class MemoryManager { private entries: MemoryEntry[] = []; constructor() { this.load(); } // === Persistence === private load(): void { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { this.entries = JSON.parse(raw); } } catch (err) { console.warn('[MemoryManager] Failed to load memories:', err); this.entries = []; } } private persist(): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(this.entries)); } catch (err) { console.warn('[MemoryManager] Failed to persist memories:', err); } } // === Write === async save( entry: Omit ): Promise { const now = new Date().toISOString(); const newEntry: MemoryEntry = { ...entry, id: generateMemoryId(), createdAt: now, lastAccessedAt: now, accessCount: 0, }; // Deduplicate: check if very similar content already exists for this agent const duplicate = this.entries.find(e => e.agentId === entry.agentId && e.type === entry.type && this.contentSimilarity(e.content, entry.content) >= 0.8 ); if (duplicate) { // Update existing entry instead of creating duplicate duplicate.content = entry.content; duplicate.importance = Math.max(duplicate.importance, entry.importance); duplicate.lastAccessedAt = now; duplicate.accessCount++; duplicate.tags = [...new Set([...duplicate.tags, ...entry.tags])]; this.persist(); return duplicate; } this.entries.push(newEntry); this.persist(); return newEntry; } // === Search === async search(query: string, options?: MemorySearchOptions): Promise { const queryTokens = tokenize(query); if (queryTokens.length === 0) return []; let candidates = [...this.entries]; // Filter by options if (options?.agentId) { candidates = candidates.filter(e => e.agentId === options.agentId); } if (options?.type) { candidates = candidates.filter(e => e.type === options.type); } if (options?.types && options.types.length > 0) { candidates = candidates.filter(e => options.types!.includes(e.type)); } if (options?.tags && options.tags.length > 0) { candidates = candidates.filter(e => options.tags!.some(tag => e.tags.includes(tag)) ); } if (options?.minImportance !== undefined) { candidates = candidates.filter(e => e.importance >= options.minImportance!); } // Score and rank const scored = candidates .map(entry => ({ entry, score: searchScore(entry, queryTokens) })) .filter(item => item.score > 0) .sort((a, b) => b.score - a.score); const limit = options?.limit ?? 10; const results = scored.slice(0, limit).map(item => item.entry); // Update access metadata const now = new Date().toISOString(); for (const entry of results) { entry.lastAccessedAt = now; entry.accessCount++; } if (results.length > 0) { this.persist(); } return results; } // === Get All (for an agent) === async getAll(agentId: string, options?: { type?: MemoryType; limit?: number }): Promise { let results = this.entries.filter(e => e.agentId === agentId); if (options?.type) { results = results.filter(e => e.type === options.type); } results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); if (options?.limit) { results = results.slice(0, options.limit); } return results; } // === Get by ID === async get(id: string): Promise { return this.entries.find(e => e.id === id) ?? null; } // === Forget === async forget(id: string): Promise { this.entries = this.entries.filter(e => e.id !== id); this.persist(); } // === Prune (bulk cleanup) === async prune(options: { maxAgeDays?: number; minImportance?: number; agentId?: string; }): Promise { const before = this.entries.length; const now = Date.now(); this.entries = this.entries.filter(entry => { if (options.agentId && entry.agentId !== options.agentId) return true; // keep other agents const ageDays = (now - new Date(entry.lastAccessedAt).getTime()) / (24 * 60 * 60 * 1000); const tooOld = options.maxAgeDays !== undefined && ageDays > options.maxAgeDays; const tooLow = options.minImportance !== undefined && entry.importance < options.minImportance; // Only prune if both conditions met (old AND low importance) if (tooOld && tooLow) return false; return true; }); const pruned = before - this.entries.length; if (pruned > 0) { this.persist(); } return pruned; } // === Export to Markdown === async exportToMarkdown(agentId: string): Promise { const agentEntries = this.entries .filter(e => e.agentId === agentId) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); if (agentEntries.length === 0) { return `# Agent Memory Export\n\n_No memories recorded._\n`; } const sections: string[] = [`# Agent Memory Export\n\n> Agent: ${agentId}\n> Exported: ${new Date().toISOString()}\n> Total entries: ${agentEntries.length}\n`]; const byType = new Map(); for (const entry of agentEntries) { const list = byType.get(entry.type) || []; list.push(entry); byType.set(entry.type, list); } const typeLabels: Record = { fact: '📋 事实', preference: '⭐ 偏好', lesson: '💡 经验教训', context: '📌 上下文', task: '📝 任务', }; for (const [type, entries] of byType) { sections.push(`\n## ${typeLabels[type] || type}\n`); for (const entry of entries) { const tags = entry.tags.length > 0 ? ` [${entry.tags.join(', ')}]` : ''; sections.push(`- **[重要性:${entry.importance}]** ${entry.content}${tags}`); sections.push(` _创建: ${entry.createdAt} | 访问: ${entry.accessCount}次_\n`); } } return sections.join('\n'); } // === Stats === async stats(agentId?: string): Promise { const entries = agentId ? this.entries.filter(e => e.agentId === agentId) : this.entries; const byType: Record = {}; const byAgent: Record = {}; for (const entry of entries) { byType[entry.type] = (byType[entry.type] || 0) + 1; byAgent[entry.agentId] = (byAgent[entry.agentId] || 0) + 1; } const sorted = [...entries].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); return { totalEntries: entries.length, byType, byAgent, oldestEntry: sorted[0]?.createdAt ?? null, newestEntry: sorted[sorted.length - 1]?.createdAt ?? null, }; } // === Update importance === async updateImportance(id: string, importance: number): Promise { const entry = this.entries.find(e => e.id === id); if (entry) { entry.importance = Math.max(0, Math.min(10, importance)); this.persist(); } } // === Helpers === private contentSimilarity(a: string, b: string): number { const tokensA = new Set(tokenize(a)); const tokensB = new Set(tokenize(b)); if (tokensA.size === 0 || tokensB.size === 0) return 0; let intersection = 0; for (const t of tokensA) { if (tokensB.has(t)) intersection++; } return (2 * intersection) / (tokensA.size + tokensB.size); } } // === Singleton === let _instance: MemoryManager | null = null; export function getMemoryManager(): MemoryManager { if (!_instance) { _instance = new MemoryManager(); } return _instance; } export function resetMemoryManager(): void { _instance = null; }