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>
369 lines
10 KiB
TypeScript
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;
|
|
}
|