Files
zclaw_openfang/desktop/src/lib/agent-memory.ts
iven 0b89329e19 feat(l4): upgrade engines with LLM-powered capabilities (Phase 2)
Phase 2 LLM Engine Upgrades:
- ReflectionEngine: Add LLM semantic analysis for pattern detection
- ContextCompactor: Add LLM summarization for high-quality compaction
- MemoryExtractor: Add LLM importance scoring for memory extraction
- Add unified LLM service adapter (OpenAI, Volcengine, Gateway, Mock)
- Add MemorySource 'llm-reflection' for LLM-generated memories
- Add 13 integration tests for LLM-powered features

Config options added:
- useLLM: Enable LLM mode for each engine
- llmProvider: Preferred LLM provider
- llmFallbackToRules: Fallback to rules if LLM fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:41:03 +08:00

369 lines
10 KiB
TypeScript

/**
* 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<string, number>;
byAgent: Record<string, number>;
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<MemoryEntry, 'id' | 'createdAt' | 'lastAccessedAt' | 'accessCount'>
): Promise<MemoryEntry> {
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<MemoryEntry[]> {
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<MemoryEntry[]> {
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<MemoryEntry | null> {
return this.entries.find(e => e.id === id) ?? null;
}
// === Forget ===
async forget(id: string): Promise<void> {
this.entries = this.entries.filter(e => e.id !== id);
this.persist();
}
// === Prune (bulk cleanup) ===
async prune(options: {
maxAgeDays?: number;
minImportance?: number;
agentId?: string;
}): Promise<number> {
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<string> {
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<string, MemoryEntry[]>();
for (const entry of agentEntries) {
const list = byType.get(entry.type) || [];
list.push(entry);
byType.set(entry.type, list);
}
const typeLabels: Record<string, string> = {
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<MemoryStats> {
const entries = agentId
? this.entries.filter(e => e.agentId === agentId)
: this.entries;
const byType: Record<string, number> = {};
const byAgent: Record<string, number> = {};
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<void> {
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;
}