feat: implement ZCLAW Agent Intelligence Evolution Phase 1-3
Phase 1: Persistent Memory + Identity Dynamic Evolution - agent-memory.ts: MemoryManager with localStorage persistence, keyword search, deduplication, importance scoring, pruning, markdown export - agent-identity.ts: AgentIdentityManager with per-agent SOUL/AGENTS/USER.md, change proposals with approval workflow, snapshot rollback - memory-extractor.ts: Rule-based conversation memory extraction (Phase 1), LLM extraction prompt ready for Phase 2 - MemoryPanel.tsx: Memory browsing UI with search, type filter, delete, export (integrated as 4th tab in RightPanel) Phase 2: Context Governance - context-compactor.ts: Token estimation, threshold monitoring (soft/hard), memory flush before compaction, rule-based summarization - chatStore integration: auto-compact when approaching token limits Phase 3: Proactive Intelligence + Self-Reflection - heartbeat-engine.ts: Periodic checks (pending tasks, memory health, idle greeting), quiet hours, proactivity levels (silent/light/standard/autonomous) - reflection-engine.ts: Pattern analysis from memory corpus, improvement suggestions, identity change proposals, meta-memory creation Chat Flow Integration (chatStore.ts): - Pre-send: context compaction check -> memory search -> identity system prompt injection - Post-complete: async memory extraction -> reflection conversation tracking -> auto-trigger reflection Tests: 274 passing across 12 test files - agent-memory.test.ts: 42 tests - context-compactor.test.ts: 23 tests - heartbeat-reflection.test.ts: 28 tests - chatStore.test.ts: 11 tests (no regressions) Refs: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md updated with implementation progress
This commit is contained in:
368
desktop/src/lib/agent-memory.ts
Normal file
368
desktop/src/lib/agent-memory.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user