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:
iven
2026-03-15 22:24:57 +08:00
parent 4862e79b2b
commit 04ddf94123
13 changed files with 3949 additions and 26 deletions

View 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;
}