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:
360
desktop/src/components/MemoryPanel.tsx
Normal file
360
desktop/src/components/MemoryPanel.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Brain, Search, Trash2, Download, Star, Tag, Clock,
|
||||
ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
import { Button, Badge, EmptyState } from './ui';
|
||||
import {
|
||||
getMemoryManager,
|
||||
type MemoryEntry,
|
||||
type MemoryType,
|
||||
type MemoryStats,
|
||||
} from '../lib/agent-memory';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
|
||||
const TYPE_LABELS: Record<MemoryType, { label: string; emoji: string; color: string }> = {
|
||||
fact: { label: '事实', emoji: '📋', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||
preference: { label: '偏好', emoji: '⭐', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' },
|
||||
lesson: { label: '经验', emoji: '💡', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' },
|
||||
context: { label: '上下文', emoji: '📌', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' },
|
||||
task: { label: '任务', emoji: '📝', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' },
|
||||
};
|
||||
|
||||
export function MemoryPanel() {
|
||||
const { currentAgent } = useChatStore();
|
||||
const agentId = currentAgent?.id || 'zclaw-main';
|
||||
|
||||
const [memories, setMemories] = useState<MemoryEntry[]>([]);
|
||||
const [stats, setStats] = useState<MemoryStats | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<MemoryType | 'all'>('all');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const loadMemories = useCallback(async () => {
|
||||
const mgr = getMemoryManager();
|
||||
const typeFilter = filterType !== 'all' ? { type: filterType as MemoryType } : {};
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const results = await mgr.search(searchQuery, {
|
||||
agentId,
|
||||
limit: 50,
|
||||
...typeFilter,
|
||||
});
|
||||
setMemories(results);
|
||||
} else {
|
||||
const all = await mgr.getAll(agentId, { ...typeFilter, limit: 50 });
|
||||
setMemories(all);
|
||||
}
|
||||
|
||||
const s = await mgr.stats(agentId);
|
||||
setStats(s);
|
||||
}, [agentId, searchQuery, filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMemories();
|
||||
}, [loadMemories]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await getMemoryManager().forget(id);
|
||||
loadMemories();
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const md = await getMemoryManager().exportToMarkdown(agentId);
|
||||
const blob = new Blob([md], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `zclaw-memory-${agentId}-${new Date().toISOString().slice(0, 10)}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrune = async () => {
|
||||
const pruned = await getMemoryManager().prune({
|
||||
agentId,
|
||||
maxAgeDays: 30,
|
||||
minImportance: 3,
|
||||
});
|
||||
if (pruned > 0) {
|
||||
loadMemories();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-1.5">
|
||||
<Brain className="w-4 h-4 text-orange-500" />
|
||||
Agent 记忆
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
title="导出记忆"
|
||||
aria-label="Export memories"
|
||||
className="p-1"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handlePrune}
|
||||
title="清理旧记忆"
|
||||
aria-label="Prune old memories"
|
||||
className="p-1"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-1.5">
|
||||
<div className="text-lg font-bold text-orange-600">{stats.totalEntries}</div>
|
||||
<div className="text-[10px] text-gray-500">总记忆</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-1.5">
|
||||
<div className="text-lg font-bold text-blue-600">{stats.byType['fact'] || 0}</div>
|
||||
<div className="text-[10px] text-gray-500">事实</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-1.5">
|
||||
<div className="text-lg font-bold text-amber-600">{stats.byType['preference'] || 0}</div>
|
||||
<div className="text-[10px] text-gray-500">偏好</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索记忆..."
|
||||
className="w-full text-sm border border-gray-200 dark:border-gray-600 rounded-lg pl-8 pr-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-orange-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<FilterChip
|
||||
label="全部"
|
||||
active={filterType === 'all'}
|
||||
onClick={() => setFilterType('all')}
|
||||
/>
|
||||
{(Object.keys(TYPE_LABELS) as MemoryType[]).map((type) => (
|
||||
<FilterChip
|
||||
key={type}
|
||||
label={`${TYPE_LABELS[type].emoji} ${TYPE_LABELS[type].label}`}
|
||||
active={filterType === type}
|
||||
onClick={() => setFilterType(type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory List */}
|
||||
<div className="space-y-2">
|
||||
{memories.length > 0 ? (
|
||||
<AnimatePresence initial={false}>
|
||||
{memories.map((entry) => (
|
||||
<MemoryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
expanded={expandedId === entry.id}
|
||||
onToggle={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||
onDelete={() => handleDelete(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Brain className="w-8 h-8" />}
|
||||
title={searchQuery ? '未找到匹配的记忆' : '暂无记忆'}
|
||||
description={searchQuery ? '尝试不同的搜索词' : '与 Agent 交流后,记忆会自动积累'}
|
||||
className="py-6"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryCard({
|
||||
entry,
|
||||
expanded,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
entry: MemoryEntry;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const typeInfo = TYPE_LABELS[entry.type];
|
||||
const importanceStars = Math.round(entry.importance / 2);
|
||||
const timeAgo = getTimeAgo(entry.createdAt);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeInfo.color}`}>
|
||||
{typeInfo.emoji} {typeInfo.label}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200 line-clamp-2">{entry.content}</p>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-3.5 h-3.5 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Star className="w-3 h-3" />
|
||||
{'★'.repeat(importanceStars)}{'☆'.repeat(5 - importanceStars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{timeAgo}
|
||||
</span>
|
||||
{entry.tags.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Tag className="w-3 h-3" />
|
||||
{entry.tags.slice(0, 2).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="px-3 py-2 space-y-2 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-gray-500">重要性</span>
|
||||
<span className="ml-1 font-medium">{entry.importance}/10</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">来源</span>
|
||||
<span className="ml-1 font-medium">{entry.source === 'auto' ? '自动' : entry.source === 'user' ? '用户' : '反思'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">访问</span>
|
||||
<span className="ml-1 font-medium">{entry.accessCount}次</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">创建</span>
|
||||
<span className="ml-1 font-medium">{new Date(entry.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entry.tags.map((tag) => (
|
||||
<Badge key={tag} variant="default">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 text-xs px-2 py-1"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full border transition-colors ${
|
||||
active
|
||||
? 'bg-orange-100 border-orange-300 text-orange-700 dark:bg-orange-900/30 dark:border-orange-700 dark:text-orange-300'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function getTimeAgo(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
const diffMs = now - then;
|
||||
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}天前`;
|
||||
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}个月前`;
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import { useGatewayStore, type PluginStatus } from '../store/gatewayStore';
|
||||
import { toChatAgent, useChatStore } from '../store/chatStore';
|
||||
import {
|
||||
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
|
||||
MessageSquare, Cpu, FileText, User, Activity, FileCode
|
||||
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain
|
||||
} from 'lucide-react';
|
||||
import { MemoryPanel } from './MemoryPanel';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
import { Button, Badge, EmptyState } from './ui';
|
||||
|
||||
@@ -16,7 +17,7 @@ export function RightPanel() {
|
||||
connect, loadClones, loadUsageStats, loadPluginStatus, workspaceInfo, quickConfig, updateClone,
|
||||
} = useGatewayStore();
|
||||
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent'>('status');
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory'>('status');
|
||||
const [isEditingAgent, setIsEditingAgent] = useState(false);
|
||||
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
|
||||
|
||||
@@ -139,11 +140,25 @@ export function RightPanel() {
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'memory' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('memory')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Memory"
|
||||
aria-label="Memory"
|
||||
aria-selected={activeTab === 'memory'}
|
||||
role="tab"
|
||||
>
|
||||
<Brain className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
|
||||
{activeTab === 'agent' ? (
|
||||
{activeTab === 'memory' ? (
|
||||
<MemoryPanel />
|
||||
) : activeTab === 'agent' ? (
|
||||
<div className="space-y-4">
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
|
||||
339
desktop/src/lib/agent-identity.ts
Normal file
339
desktop/src/lib/agent-identity.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Agent Identity Manager - Per-agent dynamic identity files
|
||||
*
|
||||
* Manages SOUL.md, AGENTS.md, USER.md per agent with:
|
||||
* - Per-agent isolated identity directories
|
||||
* - USER.md auto-update by agent (stores learned preferences)
|
||||
* - SOUL.md/AGENTS.md change proposals (require user approval)
|
||||
* - Snapshot history for rollback
|
||||
*
|
||||
* Phase 1: localStorage-based storage (same as agent-memory.ts)
|
||||
* Upgrade path: Tauri filesystem API for real .md files
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.3
|
||||
*/
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface IdentityFiles {
|
||||
soul: string;
|
||||
instructions: string;
|
||||
userProfile: string;
|
||||
heartbeat?: string;
|
||||
}
|
||||
|
||||
export interface IdentityChangeProposal {
|
||||
id: string;
|
||||
agentId: string;
|
||||
file: 'soul' | 'instructions';
|
||||
reason: string;
|
||||
currentContent: string;
|
||||
suggestedContent: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IdentitySnapshot {
|
||||
id: string;
|
||||
agentId: string;
|
||||
files: IdentityFiles;
|
||||
timestamp: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// === Storage Keys ===
|
||||
|
||||
const IDENTITY_STORAGE_KEY = 'zclaw-agent-identities';
|
||||
const PROPOSALS_STORAGE_KEY = 'zclaw-identity-proposals';
|
||||
const SNAPSHOTS_STORAGE_KEY = 'zclaw-identity-snapshots';
|
||||
|
||||
// === Default Identity Content ===
|
||||
|
||||
const DEFAULT_SOUL = `# ZCLAW 人格
|
||||
|
||||
你是 ZCLAW(小龙虾),一个基于 OpenClaw 定制的中文 AI 助手。
|
||||
|
||||
## 核心特质
|
||||
|
||||
- **高效执行**: 你不只是出主意,你会真正动手完成任务
|
||||
- **中文优先**: 默认使用中文交流,必要时切换英文
|
||||
- **专业可靠**: 对技术问题给出精确答案,不确定时坦诚说明
|
||||
- **持续成长**: 你会记住与用户的交互,不断改进自己的服务方式
|
||||
|
||||
## 语气
|
||||
|
||||
简洁、专业、友好。避免过度客套,直接给出有用信息。`;
|
||||
|
||||
const DEFAULT_INSTRUCTIONS = `# Agent 指令
|
||||
|
||||
## 操作规范
|
||||
|
||||
1. 执行文件操作前,先确认目标路径
|
||||
2. 执行 Shell 命令前,评估安全风险
|
||||
3. 长时间任务需定期汇报进度
|
||||
4. 优先使用中文回复
|
||||
|
||||
## 记忆管理
|
||||
|
||||
- 重要的用户偏好自动记录
|
||||
- 项目上下文保存到工作区
|
||||
- 对话结束时总结关键信息`;
|
||||
|
||||
const DEFAULT_USER_PROFILE = `# 用户画像
|
||||
|
||||
_尚未收集到用户偏好信息。随着交互积累,此文件将自动更新。_`;
|
||||
|
||||
// === AgentIdentityManager Implementation ===
|
||||
|
||||
export class AgentIdentityManager {
|
||||
private identities: Map<string, IdentityFiles> = new Map();
|
||||
private proposals: IdentityChangeProposal[] = [];
|
||||
private snapshots: IdentitySnapshot[] = [];
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
// === Persistence ===
|
||||
|
||||
private load(): void {
|
||||
try {
|
||||
const rawIdentities = localStorage.getItem(IDENTITY_STORAGE_KEY);
|
||||
if (rawIdentities) {
|
||||
const parsed = JSON.parse(rawIdentities) as Record<string, IdentityFiles>;
|
||||
this.identities = new Map(Object.entries(parsed));
|
||||
}
|
||||
|
||||
const rawProposals = localStorage.getItem(PROPOSALS_STORAGE_KEY);
|
||||
if (rawProposals) {
|
||||
this.proposals = JSON.parse(rawProposals);
|
||||
}
|
||||
|
||||
const rawSnapshots = localStorage.getItem(SNAPSHOTS_STORAGE_KEY);
|
||||
if (rawSnapshots) {
|
||||
this.snapshots = JSON.parse(rawSnapshots);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[AgentIdentity] Failed to load:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const obj: Record<string, IdentityFiles> = {};
|
||||
for (const [key, value] of this.identities) {
|
||||
obj[key] = value;
|
||||
}
|
||||
localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(obj));
|
||||
localStorage.setItem(PROPOSALS_STORAGE_KEY, JSON.stringify(this.proposals));
|
||||
localStorage.setItem(SNAPSHOTS_STORAGE_KEY, JSON.stringify(this.snapshots.slice(-50))); // Keep last 50 snapshots
|
||||
} catch (err) {
|
||||
console.warn('[AgentIdentity] Failed to persist:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// === Read Identity ===
|
||||
|
||||
getIdentity(agentId: string): IdentityFiles {
|
||||
const existing = this.identities.get(agentId);
|
||||
if (existing) return { ...existing };
|
||||
|
||||
// Initialize with defaults
|
||||
const defaults: IdentityFiles = {
|
||||
soul: DEFAULT_SOUL,
|
||||
instructions: DEFAULT_INSTRUCTIONS,
|
||||
userProfile: DEFAULT_USER_PROFILE,
|
||||
};
|
||||
this.identities.set(agentId, defaults);
|
||||
this.persist();
|
||||
return { ...defaults };
|
||||
}
|
||||
|
||||
getFile(agentId: string, file: keyof IdentityFiles): string {
|
||||
const identity = this.getIdentity(agentId);
|
||||
return identity[file] || '';
|
||||
}
|
||||
|
||||
// === Build System Prompt ===
|
||||
|
||||
buildSystemPrompt(agentId: string, memoryContext?: string): string {
|
||||
const identity = this.getIdentity(agentId);
|
||||
const sections: string[] = [];
|
||||
|
||||
if (identity.soul) sections.push(identity.soul);
|
||||
if (identity.instructions) sections.push(identity.instructions);
|
||||
if (identity.userProfile && identity.userProfile !== DEFAULT_USER_PROFILE) {
|
||||
sections.push(`## 用户画像\n${identity.userProfile}`);
|
||||
}
|
||||
if (memoryContext) {
|
||||
sections.push(memoryContext);
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
// === Update USER.md (auto, no approval needed) ===
|
||||
|
||||
updateUserProfile(agentId: string, newContent: string): void {
|
||||
const identity = this.getIdentity(agentId);
|
||||
const oldContent = identity.userProfile;
|
||||
|
||||
// Create snapshot before update
|
||||
this.createSnapshot(agentId, 'Auto-update USER.md');
|
||||
|
||||
identity.userProfile = newContent;
|
||||
this.identities.set(agentId, identity);
|
||||
this.persist();
|
||||
|
||||
console.log(`[AgentIdentity] Updated USER.md for ${agentId} (${oldContent.length} → ${newContent.length} chars)`);
|
||||
}
|
||||
|
||||
appendToUserProfile(agentId: string, addition: string): void {
|
||||
const identity = this.getIdentity(agentId);
|
||||
const updated = identity.userProfile.trimEnd() + '\n\n' + addition;
|
||||
this.updateUserProfile(agentId, updated);
|
||||
}
|
||||
|
||||
// === Update SOUL.md / AGENTS.md (requires approval) ===
|
||||
|
||||
proposeChange(
|
||||
agentId: string,
|
||||
file: 'soul' | 'instructions',
|
||||
suggestedContent: string,
|
||||
reason: string
|
||||
): IdentityChangeProposal {
|
||||
const identity = this.getIdentity(agentId);
|
||||
const currentContent = file === 'soul' ? identity.soul : identity.instructions;
|
||||
|
||||
const proposal: IdentityChangeProposal = {
|
||||
id: `prop_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
agentId,
|
||||
file,
|
||||
reason,
|
||||
currentContent,
|
||||
suggestedContent,
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.proposals.push(proposal);
|
||||
this.persist();
|
||||
return proposal;
|
||||
}
|
||||
|
||||
approveProposal(proposalId: string): boolean {
|
||||
const proposal = this.proposals.find(p => p.id === proposalId);
|
||||
if (!proposal || proposal.status !== 'pending') return false;
|
||||
|
||||
const identity = this.getIdentity(proposal.agentId);
|
||||
this.createSnapshot(proposal.agentId, `Approved proposal: ${proposal.reason}`);
|
||||
|
||||
if (proposal.file === 'soul') {
|
||||
identity.soul = proposal.suggestedContent;
|
||||
} else {
|
||||
identity.instructions = proposal.suggestedContent;
|
||||
}
|
||||
|
||||
this.identities.set(proposal.agentId, identity);
|
||||
proposal.status = 'approved';
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
rejectProposal(proposalId: string): boolean {
|
||||
const proposal = this.proposals.find(p => p.id === proposalId);
|
||||
if (!proposal || proposal.status !== 'pending') return false;
|
||||
|
||||
proposal.status = 'rejected';
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
getPendingProposals(agentId?: string): IdentityChangeProposal[] {
|
||||
return this.proposals.filter(p =>
|
||||
p.status === 'pending' && (!agentId || p.agentId === agentId)
|
||||
);
|
||||
}
|
||||
|
||||
// === Direct Edit (user explicitly edits in UI) ===
|
||||
|
||||
updateFile(agentId: string, file: keyof IdentityFiles, content: string): void {
|
||||
const identity = this.getIdentity(agentId);
|
||||
this.createSnapshot(agentId, `Manual edit: ${file}`);
|
||||
|
||||
identity[file] = content;
|
||||
this.identities.set(agentId, identity);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// === Snapshots ===
|
||||
|
||||
private snapshotCounter = 0;
|
||||
|
||||
private createSnapshot(agentId: string, reason: string): void {
|
||||
const identity = this.getIdentity(agentId);
|
||||
this.snapshotCounter++;
|
||||
this.snapshots.push({
|
||||
id: `snap_${Date.now()}_${this.snapshotCounter}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
agentId,
|
||||
files: { ...identity },
|
||||
timestamp: new Date().toISOString(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
getSnapshots(agentId: string, limit: number = 10): IdentitySnapshot[] {
|
||||
// Return newest first; use array index as tiebreaker for same-millisecond snapshots
|
||||
const filtered = this.snapshots
|
||||
.map((s, idx) => ({ s, idx }))
|
||||
.filter(({ s }) => s.agentId === agentId)
|
||||
.sort((a, b) => {
|
||||
const timeDiff = new Date(b.s.timestamp).getTime() - new Date(a.s.timestamp).getTime();
|
||||
return timeDiff !== 0 ? timeDiff : b.idx - a.idx;
|
||||
})
|
||||
.map(({ s }) => s);
|
||||
return filtered.slice(0, limit);
|
||||
}
|
||||
|
||||
restoreSnapshot(agentId: string, snapshotId: string): boolean {
|
||||
const snapshot = this.snapshots.find(s =>
|
||||
s.agentId === agentId && s.id === snapshotId
|
||||
);
|
||||
if (!snapshot) return false;
|
||||
|
||||
this.createSnapshot(agentId, `Rollback to ${snapshot.timestamp}`);
|
||||
this.identities.set(agentId, { ...snapshot.files });
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
// === List agents ===
|
||||
|
||||
listAgents(): string[] {
|
||||
return [...this.identities.keys()];
|
||||
}
|
||||
|
||||
// === Delete agent identity ===
|
||||
|
||||
deleteAgent(agentId: string): void {
|
||||
this.identities.delete(agentId);
|
||||
this.proposals = this.proposals.filter(p => p.agentId !== agentId);
|
||||
this.snapshots = this.snapshots.filter(s => s.agentId !== agentId);
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: AgentIdentityManager | null = null;
|
||||
|
||||
export function getAgentIdentityManager(): AgentIdentityManager {
|
||||
if (!_instance) {
|
||||
_instance = new AgentIdentityManager();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetAgentIdentityManager(): void {
|
||||
_instance = null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
361
desktop/src/lib/context-compactor.ts
Normal file
361
desktop/src/lib/context-compactor.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Context Compactor - Manages infinite-length conversations without losing key info
|
||||
*
|
||||
* Flow:
|
||||
* 1. Monitor token count against soft threshold
|
||||
* 2. When threshold approached: flush memories from old messages
|
||||
* 3. Summarize old messages into a compact system message
|
||||
* 4. Replace old messages with summary — user sees no interruption
|
||||
*
|
||||
* Phase 2 implementation: heuristic token estimation + rule-based summarization.
|
||||
* Phase 3 upgrade: LLM-powered summarization + semantic importance scoring.
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.3.1
|
||||
*/
|
||||
|
||||
import { getMemoryExtractor, type ConversationMessage } from './memory-extractor';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface CompactionConfig {
|
||||
softThresholdTokens: number; // Trigger compaction when approaching this (default 15000)
|
||||
hardThresholdTokens: number; // Force compaction at this limit (default 20000)
|
||||
reserveTokens: number; // Reserve for new messages (default 4000)
|
||||
memoryFlushEnabled: boolean; // Extract memories before compacting (default true)
|
||||
keepRecentMessages: number; // Always keep this many recent messages (default 6)
|
||||
summaryMaxTokens: number; // Max tokens for the compaction summary (default 800)
|
||||
}
|
||||
|
||||
export interface CompactableMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
id?: string;
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface CompactionResult {
|
||||
compactedMessages: CompactableMessage[];
|
||||
summary: string;
|
||||
originalCount: number;
|
||||
retainedCount: number;
|
||||
flushedMemories: number;
|
||||
tokensBeforeCompaction: number;
|
||||
tokensAfterCompaction: number;
|
||||
}
|
||||
|
||||
export interface CompactionCheck {
|
||||
shouldCompact: boolean;
|
||||
currentTokens: number;
|
||||
threshold: number;
|
||||
urgency: 'none' | 'soft' | 'hard';
|
||||
}
|
||||
|
||||
// === Default Config ===
|
||||
|
||||
export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
|
||||
softThresholdTokens: 15000,
|
||||
hardThresholdTokens: 20000,
|
||||
reserveTokens: 4000,
|
||||
memoryFlushEnabled: true,
|
||||
keepRecentMessages: 6,
|
||||
summaryMaxTokens: 800,
|
||||
};
|
||||
|
||||
// === Token Estimation ===
|
||||
|
||||
/**
|
||||
* Heuristic token count estimation.
|
||||
* CJK characters ≈ 1.5 tokens each, English words ≈ 1.3 tokens each.
|
||||
* This is intentionally conservative (overestimates) to avoid hitting real limits.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
|
||||
let tokens = 0;
|
||||
for (const char of text) {
|
||||
const code = char.codePointAt(0) || 0;
|
||||
if (code >= 0x4E00 && code <= 0x9FFF) {
|
||||
tokens += 1.5; // CJK ideographs
|
||||
} else if (code >= 0x3400 && code <= 0x4DBF) {
|
||||
tokens += 1.5; // CJK Extension A
|
||||
} else if (code >= 0x3000 && code <= 0x303F) {
|
||||
tokens += 1; // CJK punctuation
|
||||
} else if (char === ' ' || char === '\n' || char === '\t') {
|
||||
tokens += 0.25; // whitespace
|
||||
} else {
|
||||
tokens += 0.3; // ASCII chars (roughly 4 chars per token for English)
|
||||
}
|
||||
}
|
||||
|
||||
return Math.ceil(tokens);
|
||||
}
|
||||
|
||||
export function estimateMessagesTokens(messages: CompactableMessage[]): number {
|
||||
let total = 0;
|
||||
for (const msg of messages) {
|
||||
total += estimateTokens(msg.content);
|
||||
total += 4; // message framing overhead (role, separators)
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// === Context Compactor ===
|
||||
|
||||
export class ContextCompactor {
|
||||
private config: CompactionConfig;
|
||||
|
||||
constructor(config?: Partial<CompactionConfig>) {
|
||||
this.config = { ...DEFAULT_COMPACTION_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if compaction is needed based on current message token count.
|
||||
*/
|
||||
checkThreshold(messages: CompactableMessage[]): CompactionCheck {
|
||||
const currentTokens = estimateMessagesTokens(messages);
|
||||
|
||||
if (currentTokens >= this.config.hardThresholdTokens) {
|
||||
return { shouldCompact: true, currentTokens, threshold: this.config.hardThresholdTokens, urgency: 'hard' };
|
||||
}
|
||||
|
||||
if (currentTokens >= this.config.softThresholdTokens) {
|
||||
return { shouldCompact: true, currentTokens, threshold: this.config.softThresholdTokens, urgency: 'soft' };
|
||||
}
|
||||
|
||||
return { shouldCompact: false, currentTokens, threshold: this.config.softThresholdTokens, urgency: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute memory flush: extract memories from messages about to be compacted.
|
||||
*/
|
||||
async memoryFlush(
|
||||
messagesToCompact: CompactableMessage[],
|
||||
agentId: string,
|
||||
conversationId?: string
|
||||
): Promise<number> {
|
||||
if (!this.config.memoryFlushEnabled) return 0;
|
||||
|
||||
try {
|
||||
const extractor = getMemoryExtractor();
|
||||
const convMessages: ConversationMessage[] = messagesToCompact.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const result = await extractor.extractFromConversation(convMessages, agentId, conversationId);
|
||||
return result.saved;
|
||||
} catch (err) {
|
||||
console.warn('[ContextCompactor] Memory flush failed:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute compaction: summarize old messages, keep recent ones.
|
||||
*
|
||||
* Phase 2: Rule-based summarization (extract key points heuristically).
|
||||
* Phase 3: LLM-powered summarization.
|
||||
*/
|
||||
async compact(
|
||||
messages: CompactableMessage[],
|
||||
agentId: string,
|
||||
conversationId?: string
|
||||
): Promise<CompactionResult> {
|
||||
const tokensBeforeCompaction = estimateMessagesTokens(messages);
|
||||
const keepCount = Math.min(this.config.keepRecentMessages, messages.length);
|
||||
|
||||
// Split: old messages to compact vs recent to keep
|
||||
const splitIndex = messages.length - keepCount;
|
||||
const oldMessages = messages.slice(0, splitIndex);
|
||||
const recentMessages = messages.slice(splitIndex);
|
||||
|
||||
// Step 1: Memory flush from old messages
|
||||
let flushedMemories = 0;
|
||||
if (oldMessages.length > 0) {
|
||||
flushedMemories = await this.memoryFlush(oldMessages, agentId, conversationId);
|
||||
}
|
||||
|
||||
// Step 2: Generate summary of old messages
|
||||
const summary = this.generateSummary(oldMessages);
|
||||
|
||||
// Step 3: Build compacted message list
|
||||
const summaryMessage: CompactableMessage = {
|
||||
role: 'system',
|
||||
content: summary,
|
||||
id: `compaction_${Date.now()}`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const compactedMessages = [summaryMessage, ...recentMessages];
|
||||
const tokensAfterCompaction = estimateMessagesTokens(compactedMessages);
|
||||
|
||||
console.log(
|
||||
`[ContextCompactor] Compacted: ${messages.length} → ${compactedMessages.length} messages, ` +
|
||||
`${tokensBeforeCompaction} → ${tokensAfterCompaction} tokens, ` +
|
||||
`${flushedMemories} memories flushed`
|
||||
);
|
||||
|
||||
return {
|
||||
compactedMessages,
|
||||
summary,
|
||||
originalCount: messages.length,
|
||||
retainedCount: compactedMessages.length,
|
||||
flushedMemories,
|
||||
tokensBeforeCompaction,
|
||||
tokensAfterCompaction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: Rule-based summary generation.
|
||||
* Extracts key topics, decisions, and action items from old messages.
|
||||
*/
|
||||
private generateSummary(messages: CompactableMessage[]): string {
|
||||
if (messages.length === 0) return '[对话开始]';
|
||||
|
||||
const sections: string[] = ['[以下是之前对话的摘要]'];
|
||||
|
||||
// Extract user questions/topics
|
||||
const userMessages = messages.filter(m => m.role === 'user');
|
||||
const assistantMessages = messages.filter(m => m.role === 'assistant');
|
||||
|
||||
// Summarize topics discussed
|
||||
if (userMessages.length > 0) {
|
||||
const topics = userMessages
|
||||
.map(m => this.extractTopic(m.content))
|
||||
.filter(Boolean);
|
||||
|
||||
if (topics.length > 0) {
|
||||
sections.push(`讨论主题: ${topics.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract key decisions/conclusions from assistant
|
||||
if (assistantMessages.length > 0) {
|
||||
const conclusions = assistantMessages
|
||||
.flatMap(m => this.extractConclusions(m.content))
|
||||
.slice(0, 5);
|
||||
|
||||
if (conclusions.length > 0) {
|
||||
sections.push(`关键结论:\n${conclusions.map(c => `- ${c}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract any code/technical context
|
||||
const technicalContext = messages
|
||||
.filter(m => m.content.includes('```') || m.content.includes('function ') || m.content.includes('class '))
|
||||
.map(m => {
|
||||
const codeMatch = m.content.match(/```(\w+)?[\s\S]*?```/);
|
||||
return codeMatch ? `代码片段 (${codeMatch[1] || 'code'})` : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (technicalContext.length > 0) {
|
||||
sections.push(`技术上下文: ${technicalContext.join(', ')}`);
|
||||
}
|
||||
|
||||
// Message count summary
|
||||
sections.push(`(已压缩 ${messages.length} 条消息,其中用户 ${userMessages.length} 条,助手 ${assistantMessages.length} 条)`);
|
||||
|
||||
const summary = sections.join('\n');
|
||||
|
||||
// Enforce token limit
|
||||
const summaryTokens = estimateTokens(summary);
|
||||
if (summaryTokens > this.config.summaryMaxTokens) {
|
||||
return summary.slice(0, this.config.summaryMaxTokens * 2) + '\n...(摘要已截断)';
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the main topic from a user message (first 50 chars or first sentence).
|
||||
*/
|
||||
private extractTopic(content: string): string {
|
||||
const trimmed = content.trim();
|
||||
// First sentence or first 50 chars
|
||||
const sentenceEnd = trimmed.search(/[。!?\n]/);
|
||||
if (sentenceEnd > 0 && sentenceEnd <= 80) {
|
||||
return trimmed.slice(0, sentenceEnd + 1);
|
||||
}
|
||||
if (trimmed.length <= 50) return trimmed;
|
||||
return trimmed.slice(0, 50) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract key conclusions/decisions from assistant messages.
|
||||
*/
|
||||
private extractConclusions(content: string): string[] {
|
||||
const conclusions: string[] = [];
|
||||
const patterns = [
|
||||
/(?:总结|结论|关键点|建议|方案)[::]\s*(.{10,100})/g,
|
||||
/(?:\d+\.\s+)(.{10,80})/g,
|
||||
/(?:需要|应该|可以|建议)(.{5,60})/g,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matches = content.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
const text = match[1]?.trim() || match[0].trim();
|
||||
if (text.length > 10 && text.length < 100) {
|
||||
conclusions.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conclusions.slice(0, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the LLM compaction prompt for Phase 3.
|
||||
* Returns the prompt to send to LLM for generating a high-quality summary.
|
||||
*/
|
||||
buildCompactionPrompt(messages: CompactableMessage[]): string {
|
||||
const conversationText = messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => `[${m.role === 'user' ? '用户' : '助手'}]: ${m.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `请将以下对话压缩为简洁摘要,保留:
|
||||
1. 用户提出的所有问题和需求
|
||||
2. 达成的关键决策和结论
|
||||
3. 重要的技术上下文(文件路径、配置、代码片段名称)
|
||||
4. 未完成的任务或待办事项
|
||||
|
||||
输出格式:
|
||||
- 讨论主题: ...
|
||||
- 关键决策: ...
|
||||
- 技术上下文: ...
|
||||
- 待办事项: ...
|
||||
|
||||
请用中文输出,控制在 300 字以内。
|
||||
|
||||
对话内容:
|
||||
${conversationText}`;
|
||||
}
|
||||
|
||||
// === Config Management ===
|
||||
|
||||
getConfig(): CompactionConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
updateConfig(updates: Partial<CompactionConfig>): void {
|
||||
this.config = { ...this.config, ...updates };
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: ContextCompactor | null = null;
|
||||
|
||||
export function getContextCompactor(config?: Partial<CompactionConfig>): ContextCompactor {
|
||||
if (!_instance) {
|
||||
_instance = new ContextCompactor(config);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetContextCompactor(): void {
|
||||
_instance = null;
|
||||
}
|
||||
346
desktop/src/lib/heartbeat-engine.ts
Normal file
346
desktop/src/lib/heartbeat-engine.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Heartbeat Engine - Periodic proactive checks for ZCLAW agents
|
||||
*
|
||||
* Runs on a configurable interval, executing a checklist of items.
|
||||
* Each check can produce alerts that surface via desktop notification or UI.
|
||||
* Supports quiet hours (no notifications during sleep time).
|
||||
*
|
||||
* Phase 3 implementation: rule-based checks with configurable checklist.
|
||||
* Phase 4 upgrade: LLM-powered interpretation of HEARTBEAT.md checklists.
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.1
|
||||
*/
|
||||
|
||||
import { getMemoryManager } from './agent-memory';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface HeartbeatConfig {
|
||||
enabled: boolean;
|
||||
intervalMinutes: number;
|
||||
quietHoursStart?: string; // "22:00" format
|
||||
quietHoursEnd?: string; // "08:00" format
|
||||
notifyChannel: 'desktop' | 'ui' | 'all';
|
||||
proactivityLevel: 'silent' | 'light' | 'standard' | 'autonomous';
|
||||
maxAlertsPerTick: number;
|
||||
}
|
||||
|
||||
export interface HeartbeatAlert {
|
||||
title: string;
|
||||
content: string;
|
||||
urgency: 'low' | 'medium' | 'high';
|
||||
source: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatResult {
|
||||
status: 'ok' | 'alert';
|
||||
alerts: HeartbeatAlert[];
|
||||
checkedItems: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type HeartbeatCheckFn = (agentId: string) => Promise<HeartbeatAlert | null>;
|
||||
|
||||
// === Default Config ===
|
||||
|
||||
export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = {
|
||||
enabled: false,
|
||||
intervalMinutes: 30,
|
||||
quietHoursStart: '22:00',
|
||||
quietHoursEnd: '08:00',
|
||||
notifyChannel: 'ui',
|
||||
proactivityLevel: 'light',
|
||||
maxAlertsPerTick: 5,
|
||||
};
|
||||
|
||||
// === Built-in Checks ===
|
||||
|
||||
/** Check if agent has unresolved task memories */
|
||||
async function checkPendingTasks(agentId: string): Promise<HeartbeatAlert | null> {
|
||||
const mgr = getMemoryManager();
|
||||
const tasks = await mgr.getAll(agentId, { type: 'task', limit: 10 });
|
||||
const pending = tasks.filter(t => t.importance >= 6);
|
||||
|
||||
if (pending.length > 0) {
|
||||
return {
|
||||
title: '待办任务提醒',
|
||||
content: `有 ${pending.length} 个待处理任务:${pending.slice(0, 3).map(t => t.content).join(';')}`,
|
||||
urgency: pending.some(t => t.importance >= 8) ? 'high' : 'medium',
|
||||
source: 'pending-tasks',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if memory storage is getting large and might need pruning */
|
||||
async function checkMemoryHealth(agentId: string): Promise<HeartbeatAlert | null> {
|
||||
const mgr = getMemoryManager();
|
||||
const stats = await mgr.stats(agentId);
|
||||
|
||||
if (stats.totalEntries > 500) {
|
||||
return {
|
||||
title: '记忆存储提醒',
|
||||
content: `已积累 ${stats.totalEntries} 条记忆,建议清理低重要性的旧记忆以保持检索效率。`,
|
||||
urgency: stats.totalEntries > 1000 ? 'high' : 'low',
|
||||
source: 'memory-health',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if it's been a while since last interaction (greeting opportunity) */
|
||||
async function checkIdleGreeting(_agentId: string): Promise<HeartbeatAlert | null> {
|
||||
// Check localStorage for last interaction time
|
||||
try {
|
||||
const lastInteraction = localStorage.getItem('zclaw-last-interaction');
|
||||
if (lastInteraction) {
|
||||
const elapsed = Date.now() - parseInt(lastInteraction, 10);
|
||||
const hoursSinceInteraction = elapsed / (1000 * 60 * 60);
|
||||
|
||||
// Only greet on weekdays between 9am-6pm if idle for > 4 hours
|
||||
const now = new Date();
|
||||
const isWeekday = now.getDay() >= 1 && now.getDay() <= 5;
|
||||
const isWorkHours = now.getHours() >= 9 && now.getHours() <= 18;
|
||||
|
||||
if (isWeekday && isWorkHours && hoursSinceInteraction > 4) {
|
||||
return {
|
||||
title: '闲置提醒',
|
||||
content: `已有 ${Math.floor(hoursSinceInteraction)} 小时未交互。需要我帮你处理什么吗?`,
|
||||
urgency: 'low',
|
||||
source: 'idle-greeting',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available in test
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// === Heartbeat Engine ===
|
||||
|
||||
const HISTORY_STORAGE_KEY = 'zclaw-heartbeat-history';
|
||||
|
||||
export class HeartbeatEngine {
|
||||
private config: HeartbeatConfig;
|
||||
private timerId: ReturnType<typeof setInterval> | null = null;
|
||||
private checks: HeartbeatCheckFn[] = [];
|
||||
private history: HeartbeatResult[] = [];
|
||||
private agentId: string;
|
||||
private onAlert?: (alerts: HeartbeatAlert[]) => void;
|
||||
|
||||
constructor(agentId: string, config?: Partial<HeartbeatConfig>) {
|
||||
this.config = { ...DEFAULT_HEARTBEAT_CONFIG, ...config };
|
||||
this.agentId = agentId;
|
||||
this.loadHistory();
|
||||
|
||||
// Register built-in checks
|
||||
this.checks = [
|
||||
checkPendingTasks,
|
||||
checkMemoryHealth,
|
||||
checkIdleGreeting,
|
||||
];
|
||||
}
|
||||
|
||||
// === Lifecycle ===
|
||||
|
||||
start(onAlert?: (alerts: HeartbeatAlert[]) => void): void {
|
||||
if (this.timerId) this.stop();
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
this.onAlert = onAlert;
|
||||
const intervalMs = this.config.intervalMinutes * 60 * 1000;
|
||||
|
||||
this.timerId = setInterval(() => {
|
||||
this.tick().catch(err =>
|
||||
console.warn('[Heartbeat] Tick failed:', err)
|
||||
);
|
||||
}, intervalMs);
|
||||
|
||||
console.log(`[Heartbeat] Started for ${this.agentId}, interval: ${this.config.intervalMinutes}min`);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timerId) {
|
||||
clearInterval(this.timerId);
|
||||
this.timerId = null;
|
||||
console.log(`[Heartbeat] Stopped for ${this.agentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.timerId !== null;
|
||||
}
|
||||
|
||||
// === Single Tick ===
|
||||
|
||||
async tick(): Promise<HeartbeatResult> {
|
||||
// Quiet hours check
|
||||
if (this.isQuietHours()) {
|
||||
const result: HeartbeatResult = {
|
||||
status: 'ok',
|
||||
alerts: [],
|
||||
checkedItems: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
const alerts: HeartbeatAlert[] = [];
|
||||
|
||||
for (const check of this.checks) {
|
||||
try {
|
||||
const alert = await check(this.agentId);
|
||||
if (alert) {
|
||||
alerts.push(alert);
|
||||
if (alerts.length >= this.config.maxAlertsPerTick) break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Heartbeat] Check failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by proactivity level
|
||||
const filteredAlerts = this.filterByProactivity(alerts);
|
||||
|
||||
const result: HeartbeatResult = {
|
||||
status: filteredAlerts.length > 0 ? 'alert' : 'ok',
|
||||
alerts: filteredAlerts,
|
||||
checkedItems: this.checks.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Store history
|
||||
this.history.push(result);
|
||||
if (this.history.length > 100) {
|
||||
this.history = this.history.slice(-50);
|
||||
}
|
||||
this.saveHistory();
|
||||
|
||||
// Notify
|
||||
if (filteredAlerts.length > 0 && this.onAlert) {
|
||||
this.onAlert(filteredAlerts);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// === Custom Checks ===
|
||||
|
||||
registerCheck(check: HeartbeatCheckFn): void {
|
||||
this.checks.push(check);
|
||||
}
|
||||
|
||||
// === History ===
|
||||
|
||||
getHistory(limit: number = 20): HeartbeatResult[] {
|
||||
return this.history.slice(-limit);
|
||||
}
|
||||
|
||||
getLastResult(): HeartbeatResult | null {
|
||||
return this.history.length > 0 ? this.history[this.history.length - 1] : null;
|
||||
}
|
||||
|
||||
// === Quiet Hours ===
|
||||
|
||||
isQuietHours(): boolean {
|
||||
if (!this.config.quietHoursStart || !this.config.quietHoursEnd) return false;
|
||||
|
||||
const now = new Date();
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const [startH, startM] = this.config.quietHoursStart.split(':').map(Number);
|
||||
const [endH, endM] = this.config.quietHoursEnd.split(':').map(Number);
|
||||
const startMinutes = startH * 60 + startM;
|
||||
const endMinutes = endH * 60 + endM;
|
||||
|
||||
if (startMinutes <= endMinutes) {
|
||||
// Same-day range (e.g., 13:00-17:00)
|
||||
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
||||
} else {
|
||||
// Cross-midnight range (e.g., 22:00-08:00)
|
||||
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
// === Config ===
|
||||
|
||||
getConfig(): HeartbeatConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
updateConfig(updates: Partial<HeartbeatConfig>): void {
|
||||
const wasEnabled = this.config.enabled;
|
||||
this.config = { ...this.config, ...updates };
|
||||
|
||||
// Restart if interval changed or enabled/disabled
|
||||
if (this.timerId && (updates.intervalMinutes || updates.enabled === false)) {
|
||||
this.stop();
|
||||
if (this.config.enabled) {
|
||||
this.start(this.onAlert);
|
||||
}
|
||||
} else if (!wasEnabled && this.config.enabled) {
|
||||
this.start(this.onAlert);
|
||||
}
|
||||
}
|
||||
|
||||
// === Internal ===
|
||||
|
||||
private filterByProactivity(alerts: HeartbeatAlert[]): HeartbeatAlert[] {
|
||||
switch (this.config.proactivityLevel) {
|
||||
case 'silent':
|
||||
return []; // Never alert
|
||||
case 'light':
|
||||
return alerts.filter(a => a.urgency === 'high');
|
||||
case 'standard':
|
||||
return alerts.filter(a => a.urgency === 'high' || a.urgency === 'medium');
|
||||
case 'autonomous':
|
||||
return alerts; // Show everything
|
||||
default:
|
||||
return alerts.filter(a => a.urgency === 'high');
|
||||
}
|
||||
}
|
||||
|
||||
private loadHistory(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_STORAGE_KEY);
|
||||
if (raw) {
|
||||
this.history = JSON.parse(raw);
|
||||
}
|
||||
} catch {
|
||||
this.history = [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveHistory(): void {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(this.history.slice(-50)));
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instances: Map<string, HeartbeatEngine> = new Map();
|
||||
|
||||
export function getHeartbeatEngine(agentId: string, config?: Partial<HeartbeatConfig>): HeartbeatEngine {
|
||||
let engine = _instances.get(agentId);
|
||||
if (!engine) {
|
||||
engine = new HeartbeatEngine(agentId, config);
|
||||
_instances.set(agentId, engine);
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
export function resetHeartbeatEngines(): void {
|
||||
for (const engine of _instances.values()) {
|
||||
engine.stop();
|
||||
}
|
||||
_instances = new Map();
|
||||
}
|
||||
319
desktop/src/lib/memory-extractor.ts
Normal file
319
desktop/src/lib/memory-extractor.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Memory Extractor - Automatically extract memorable information from conversations
|
||||
*
|
||||
* Uses LLM to analyze completed conversations and extract:
|
||||
* - Facts the user shared
|
||||
* - User preferences discovered
|
||||
* - Lessons learned during problem-solving
|
||||
* - Pending tasks or commitments
|
||||
*
|
||||
* Also handles auto-updating USER.md with discovered preferences.
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.2
|
||||
*/
|
||||
|
||||
import { getMemoryManager, type MemoryType } from './agent-memory';
|
||||
import { getAgentIdentityManager } from './agent-identity';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface ExtractedItem {
|
||||
content: string;
|
||||
type: MemoryType;
|
||||
importance: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ExtractionResult {
|
||||
items: ExtractedItem[];
|
||||
saved: number;
|
||||
skipped: number;
|
||||
userProfileUpdated: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// === Extraction Prompt ===
|
||||
|
||||
const EXTRACTION_PROMPT = `请从以下对话中提取值得长期记住的信息。
|
||||
|
||||
只提取以下类型:
|
||||
- fact: 用户告知的事实(如"我的公司叫 XXX"、"我在做 YYY 项目")
|
||||
- preference: 用户的偏好(如"我喜欢简洁的回答"、"请用中文")
|
||||
- lesson: 本次对话的经验教训(如"调用 API 前需要先验证 token")
|
||||
- task: 未完成的任务或承诺(如"下次帮我检查 XXX")
|
||||
|
||||
评估规则:
|
||||
- importance 1-3: 临时性、不太重要的信息
|
||||
- importance 4-6: 有一定参考价值的信息
|
||||
- importance 7-9: 重要的持久信息
|
||||
- importance 10: 极其关键的信息
|
||||
|
||||
输出**纯 JSON 数组**,每项包含 content, type, importance, tags[]。
|
||||
如果没有值得记忆的内容,返回空数组 []。
|
||||
不要输出任何其他内容,只输出 JSON。
|
||||
|
||||
对话内容:
|
||||
`;
|
||||
|
||||
// === Memory Extractor ===
|
||||
|
||||
export class MemoryExtractor {
|
||||
private minMessagesForExtraction = 4;
|
||||
private extractionCooldownMs = 30_000; // 30 seconds between extractions
|
||||
private lastExtractionTime = 0;
|
||||
|
||||
/**
|
||||
* Extract memories from a conversation using rule-based heuristics.
|
||||
* This is the Phase 1 approach — no LLM call needed.
|
||||
* Phase 2 will add LLM-based extraction using EXTRACTION_PROMPT.
|
||||
*/
|
||||
async extractFromConversation(
|
||||
messages: ConversationMessage[],
|
||||
agentId: string,
|
||||
conversationId?: string
|
||||
): Promise<ExtractionResult> {
|
||||
// Cooldown check
|
||||
if (Date.now() - this.lastExtractionTime < this.extractionCooldownMs) {
|
||||
return { items: [], saved: 0, skipped: 0, userProfileUpdated: false };
|
||||
}
|
||||
|
||||
// Minimum message threshold
|
||||
const chatMessages = messages.filter(m => m.role === 'user' || m.role === 'assistant');
|
||||
if (chatMessages.length < this.minMessagesForExtraction) {
|
||||
return { items: [], saved: 0, skipped: 0, userProfileUpdated: false };
|
||||
}
|
||||
|
||||
this.lastExtractionTime = Date.now();
|
||||
|
||||
// Phase 1: Rule-based extraction (pattern matching)
|
||||
const extracted = this.ruleBasedExtraction(chatMessages);
|
||||
|
||||
// Save to memory
|
||||
const memoryManager = getMemoryManager();
|
||||
let saved = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const item of extracted) {
|
||||
try {
|
||||
await memoryManager.save({
|
||||
agentId,
|
||||
content: item.content,
|
||||
type: item.type,
|
||||
importance: item.importance,
|
||||
source: 'auto',
|
||||
tags: item.tags,
|
||||
conversationId,
|
||||
});
|
||||
saved++;
|
||||
} catch {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-update USER.md with preferences
|
||||
let userProfileUpdated = false;
|
||||
const preferences = extracted.filter(e => e.type === 'preference' && e.importance >= 5);
|
||||
if (preferences.length > 0) {
|
||||
try {
|
||||
const identityManager = getAgentIdentityManager();
|
||||
const prefSummary = preferences.map(p => `- ${p.content}`).join('\n');
|
||||
identityManager.appendToUserProfile(agentId, `### 自动发现的偏好 (${new Date().toLocaleDateString('zh-CN')})\n${prefSummary}`);
|
||||
userProfileUpdated = true;
|
||||
} catch (err) {
|
||||
console.warn('[MemoryExtractor] Failed to update USER.md:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (saved > 0) {
|
||||
console.log(`[MemoryExtractor] Extracted ${saved} memories from conversation (${skipped} skipped)`);
|
||||
}
|
||||
|
||||
return { items: extracted, saved, skipped, userProfileUpdated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Rule-based extraction using pattern matching.
|
||||
* Extracts common patterns from user messages.
|
||||
*/
|
||||
private ruleBasedExtraction(messages: ConversationMessage[]): ExtractedItem[] {
|
||||
const items: ExtractedItem[] = [];
|
||||
const userMessages = messages.filter(m => m.role === 'user').map(m => m.content);
|
||||
|
||||
for (const msg of userMessages) {
|
||||
// Fact patterns
|
||||
this.extractFacts(msg, items);
|
||||
// Preference patterns
|
||||
this.extractPreferences(msg, items);
|
||||
// Task patterns
|
||||
this.extractTasks(msg, items);
|
||||
}
|
||||
|
||||
// Lesson extraction from assistant messages (error corrections, solutions)
|
||||
const assistantMessages = messages.filter(m => m.role === 'assistant').map(m => m.content);
|
||||
this.extractLessons(userMessages, assistantMessages, items);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private extractFacts(msg: string, items: ExtractedItem[]): void {
|
||||
// "我的/我们的 X 是/叫 Y" patterns
|
||||
const factPatterns = [
|
||||
/我(?:的|们的|们)(\S{1,20})(?:是|叫|名叫|名字是)(.{2,50})/g,
|
||||
/(?:公司|团队|项目|产品)(?:名|名称)?(?:是|叫)(.{2,30})/g,
|
||||
/我(?:在|正在)(?:做|开发|使用|学习)(.{2,40})/g,
|
||||
/我(?:是|做)(.{2,30})(?:的|工作)/g,
|
||||
];
|
||||
|
||||
for (const pattern of factPatterns) {
|
||||
const matches = msg.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
const content = match[0].trim();
|
||||
if (content.length > 5 && content.length < 100) {
|
||||
items.push({
|
||||
content,
|
||||
type: 'fact',
|
||||
importance: 6,
|
||||
tags: ['auto-extracted'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractPreferences(msg: string, items: ExtractedItem[]): void {
|
||||
const prefPatterns = [
|
||||
/(?:我喜欢|我偏好|我习惯|请用|请使用|默认用|我更愿意)(.{2,50})/g,
|
||||
/(?:不要|别|不用)(.{2,30})(?:了|吧)?/g,
|
||||
/(?:以后|下次|每次)(?:都)?(.{2,40})/g,
|
||||
/(?:用中文|用英文|简洁|详细|简短)(?:一点|回复|回答)?/g,
|
||||
];
|
||||
|
||||
for (const pattern of prefPatterns) {
|
||||
const matches = msg.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
const content = match[0].trim();
|
||||
if (content.length > 3 && content.length < 80) {
|
||||
items.push({
|
||||
content: `用户偏好: ${content}`,
|
||||
type: 'preference',
|
||||
importance: 5,
|
||||
tags: ['auto-extracted', 'preference'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractTasks(msg: string, items: ExtractedItem[]): void {
|
||||
const taskPatterns = [
|
||||
/(?:帮我|帮忙|记得|别忘了|下次|以后|待办)(.{5,60})/g,
|
||||
/(?:TODO|todo|FIXME|fixme)[:\s]*(.{5,60})/g,
|
||||
];
|
||||
|
||||
for (const pattern of taskPatterns) {
|
||||
const matches = msg.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
const content = match[0].trim();
|
||||
if (content.length > 5 && content.length < 100) {
|
||||
items.push({
|
||||
content,
|
||||
type: 'task',
|
||||
importance: 7,
|
||||
tags: ['auto-extracted', 'task'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractLessons(
|
||||
_userMessages: string[],
|
||||
assistantMessages: string[],
|
||||
items: ExtractedItem[]
|
||||
): void {
|
||||
// Look for error resolution patterns in assistant messages
|
||||
for (const msg of assistantMessages) {
|
||||
// "问题是/原因是/根因是" patterns
|
||||
const lessonPatterns = [
|
||||
/(?:问题是|原因是|根因是|解决方法是|关键是)(.{10,100})/g,
|
||||
/(?:需要注意|要注意|注意事项)[::](.{10,80})/g,
|
||||
];
|
||||
|
||||
for (const pattern of lessonPatterns) {
|
||||
const matches = msg.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
const content = match[0].trim();
|
||||
if (content.length > 10 && content.length < 150) {
|
||||
items.push({
|
||||
content,
|
||||
type: 'lesson',
|
||||
importance: 6,
|
||||
tags: ['auto-extracted', 'lesson'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the LLM extraction prompt for a conversation.
|
||||
* For Phase 2: send this to LLM and parse the JSON response.
|
||||
*/
|
||||
buildExtractionPrompt(messages: ConversationMessage[]): string {
|
||||
const conversationText = messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => `[${m.role === 'user' ? '用户' : '助手'}]: ${m.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
return EXTRACTION_PROMPT + conversationText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LLM extraction response.
|
||||
* For Phase 2: parse the JSON array from LLM response.
|
||||
*/
|
||||
parseExtractionResponse(response: string): ExtractedItem[] {
|
||||
try {
|
||||
// Find JSON array in response
|
||||
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) return [];
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed
|
||||
.filter((item: Record<string, unknown>) =>
|
||||
item.content && item.type && item.importance !== undefined
|
||||
)
|
||||
.map((item: Record<string, unknown>) => ({
|
||||
content: String(item.content),
|
||||
type: item.type as MemoryType,
|
||||
importance: Math.max(1, Math.min(10, Number(item.importance))),
|
||||
tags: Array.isArray(item.tags) ? item.tags.map(String) : [],
|
||||
}));
|
||||
} catch {
|
||||
console.warn('[MemoryExtractor] Failed to parse LLM extraction response');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: MemoryExtractor | null = null;
|
||||
|
||||
export function getMemoryExtractor(): MemoryExtractor {
|
||||
if (!_instance) {
|
||||
_instance = new MemoryExtractor();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetMemoryExtractor(): void {
|
||||
_instance = null;
|
||||
}
|
||||
440
desktop/src/lib/reflection-engine.ts
Normal file
440
desktop/src/lib/reflection-engine.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Reflection Engine - Agent self-improvement through conversation analysis
|
||||
*
|
||||
* Periodically analyzes recent conversations to:
|
||||
* - Identify behavioral patterns (positive and negative)
|
||||
* - Generate improvement suggestions
|
||||
* - Propose identity file changes (with user approval)
|
||||
* - Create meta-memories about agent performance
|
||||
*
|
||||
* Phase 3 implementation: rule-based pattern detection.
|
||||
* Phase 4 upgrade: LLM-powered deep reflection.
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.2
|
||||
*/
|
||||
|
||||
import { getMemoryManager, type MemoryEntry } from './agent-memory';
|
||||
import { getAgentIdentityManager, type IdentityChangeProposal } from './agent-identity';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface ReflectionConfig {
|
||||
triggerAfterConversations: number; // Reflect after N conversations (default 5)
|
||||
triggerAfterHours: number; // Reflect after N hours (default 24)
|
||||
allowSoulModification: boolean; // Can propose SOUL.md changes
|
||||
requireApproval: boolean; // Identity changes need user OK
|
||||
}
|
||||
|
||||
export interface PatternObservation {
|
||||
observation: string;
|
||||
frequency: number;
|
||||
sentiment: 'positive' | 'negative' | 'neutral';
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
export interface ImprovementSuggestion {
|
||||
area: string;
|
||||
suggestion: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface ReflectionResult {
|
||||
patterns: PatternObservation[];
|
||||
improvements: ImprovementSuggestion[];
|
||||
identityProposals: IdentityChangeProposal[];
|
||||
newMemories: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// === Default Config ===
|
||||
|
||||
export const DEFAULT_REFLECTION_CONFIG: ReflectionConfig = {
|
||||
triggerAfterConversations: 5,
|
||||
triggerAfterHours: 24,
|
||||
allowSoulModification: false,
|
||||
requireApproval: true,
|
||||
};
|
||||
|
||||
// === Storage ===
|
||||
|
||||
const REFLECTION_STORAGE_KEY = 'zclaw-reflection-state';
|
||||
const REFLECTION_HISTORY_KEY = 'zclaw-reflection-history';
|
||||
|
||||
interface ReflectionState {
|
||||
conversationsSinceReflection: number;
|
||||
lastReflectionTime: string | null;
|
||||
lastReflectionAgentId: string | null;
|
||||
}
|
||||
|
||||
// === Reflection Engine ===
|
||||
|
||||
export class ReflectionEngine {
|
||||
private config: ReflectionConfig;
|
||||
private state: ReflectionState;
|
||||
private history: ReflectionResult[] = [];
|
||||
|
||||
constructor(config?: Partial<ReflectionConfig>) {
|
||||
this.config = { ...DEFAULT_REFLECTION_CONFIG, ...config };
|
||||
this.state = this.loadState();
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
// === Trigger Management ===
|
||||
|
||||
/**
|
||||
* Call after each conversation to track when reflection should trigger.
|
||||
*/
|
||||
recordConversation(): void {
|
||||
this.state.conversationsSinceReflection++;
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's time for reflection.
|
||||
*/
|
||||
shouldReflect(): boolean {
|
||||
// Conversation count trigger
|
||||
if (this.state.conversationsSinceReflection >= this.config.triggerAfterConversations) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Time-based trigger
|
||||
if (this.state.lastReflectionTime) {
|
||||
const elapsed = Date.now() - new Date(this.state.lastReflectionTime).getTime();
|
||||
const hoursSince = elapsed / (1000 * 60 * 60);
|
||||
if (hoursSince >= this.config.triggerAfterHours) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Never reflected before, trigger after initial conversations
|
||||
return this.state.conversationsSinceReflection >= 3;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a reflection cycle for the given agent.
|
||||
*/
|
||||
async reflect(agentId: string): Promise<ReflectionResult> {
|
||||
console.log(`[Reflection] Starting reflection for agent: ${agentId}`);
|
||||
|
||||
const memoryMgr = getMemoryManager();
|
||||
const identityMgr = getAgentIdentityManager();
|
||||
|
||||
// 1. Analyze memory patterns
|
||||
const allMemories = await memoryMgr.getAll(agentId, { limit: 100 });
|
||||
const patterns = this.analyzePatterns(allMemories);
|
||||
|
||||
// 2. Generate improvement suggestions
|
||||
const improvements = this.generateImprovements(patterns, allMemories);
|
||||
|
||||
// 3. Propose identity changes if patterns warrant it
|
||||
const identityProposals: IdentityChangeProposal[] = [];
|
||||
if (this.config.allowSoulModification) {
|
||||
const proposals = this.proposeIdentityChanges(agentId, patterns, identityMgr);
|
||||
identityProposals.push(...proposals);
|
||||
}
|
||||
|
||||
// 4. Save reflection insights as memories
|
||||
let newMemories = 0;
|
||||
for (const pattern of patterns.filter(p => p.frequency >= 3)) {
|
||||
await memoryMgr.save({
|
||||
agentId,
|
||||
content: `反思观察: ${pattern.observation} (出现${pattern.frequency}次, ${pattern.sentiment === 'positive' ? '正面' : pattern.sentiment === 'negative' ? '负面' : '中性'})`,
|
||||
type: 'lesson',
|
||||
importance: pattern.sentiment === 'negative' ? 8 : 5,
|
||||
source: 'reflection',
|
||||
tags: ['reflection', 'pattern'],
|
||||
});
|
||||
newMemories++;
|
||||
}
|
||||
|
||||
for (const improvement of improvements.filter(i => i.priority === 'high')) {
|
||||
await memoryMgr.save({
|
||||
agentId,
|
||||
content: `改进方向: [${improvement.area}] ${improvement.suggestion}`,
|
||||
type: 'lesson',
|
||||
importance: 7,
|
||||
source: 'reflection',
|
||||
tags: ['reflection', 'improvement'],
|
||||
});
|
||||
newMemories++;
|
||||
}
|
||||
|
||||
// 5. Build result
|
||||
const result: ReflectionResult = {
|
||||
patterns,
|
||||
improvements,
|
||||
identityProposals,
|
||||
newMemories,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 6. Update state
|
||||
this.state.conversationsSinceReflection = 0;
|
||||
this.state.lastReflectionTime = result.timestamp;
|
||||
this.state.lastReflectionAgentId = agentId;
|
||||
this.saveState();
|
||||
|
||||
// 7. Store in history
|
||||
this.history.push(result);
|
||||
if (this.history.length > 20) {
|
||||
this.history = this.history.slice(-10);
|
||||
}
|
||||
this.saveHistory();
|
||||
|
||||
console.log(
|
||||
`[Reflection] Complete: ${patterns.length} patterns, ${improvements.length} improvements, ` +
|
||||
`${identityProposals.length} proposals, ${newMemories} memories saved`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// === Pattern Analysis ===
|
||||
|
||||
private analyzePatterns(memories: MemoryEntry[]): PatternObservation[] {
|
||||
const patterns: PatternObservation[] = [];
|
||||
|
||||
// Analyze memory type distribution
|
||||
const typeCounts = new Map<string, number>();
|
||||
for (const m of memories) {
|
||||
typeCounts.set(m.type, (typeCounts.get(m.type) || 0) + 1);
|
||||
}
|
||||
|
||||
// Pattern: Too many tasks accumulating
|
||||
const taskCount = typeCounts.get('task') || 0;
|
||||
if (taskCount >= 5) {
|
||||
patterns.push({
|
||||
observation: `积累了 ${taskCount} 个待办任务,可能存在任务管理不善`,
|
||||
frequency: taskCount,
|
||||
sentiment: 'negative',
|
||||
evidence: memories.filter(m => m.type === 'task').slice(0, 3).map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Strong preference accumulation
|
||||
const prefCount = typeCounts.get('preference') || 0;
|
||||
if (prefCount >= 5) {
|
||||
patterns.push({
|
||||
observation: `已记录 ${prefCount} 个用户偏好,对用户习惯有较好理解`,
|
||||
frequency: prefCount,
|
||||
sentiment: 'positive',
|
||||
evidence: memories.filter(m => m.type === 'preference').slice(0, 3).map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Many lessons learned
|
||||
const lessonCount = typeCounts.get('lesson') || 0;
|
||||
if (lessonCount >= 5) {
|
||||
patterns.push({
|
||||
observation: `积累了 ${lessonCount} 条经验教训,知识库在成长`,
|
||||
frequency: lessonCount,
|
||||
sentiment: 'positive',
|
||||
evidence: memories.filter(m => m.type === 'lesson').slice(0, 3).map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: High-importance items being accessed frequently
|
||||
const highAccess = memories.filter(m => m.accessCount >= 5 && m.importance >= 7);
|
||||
if (highAccess.length >= 3) {
|
||||
patterns.push({
|
||||
observation: `有 ${highAccess.length} 条高频访问的重要记忆,核心知识正在形成`,
|
||||
frequency: highAccess.length,
|
||||
sentiment: 'positive',
|
||||
evidence: highAccess.slice(0, 3).map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Low-importance memories accumulating
|
||||
const lowImportance = memories.filter(m => m.importance <= 3);
|
||||
if (lowImportance.length > 20) {
|
||||
patterns.push({
|
||||
observation: `有 ${lowImportance.length} 条低重要性记忆,建议清理`,
|
||||
frequency: lowImportance.length,
|
||||
sentiment: 'neutral',
|
||||
evidence: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Tag analysis - recurring topics
|
||||
const tagCounts = new Map<string, number>();
|
||||
for (const m of memories) {
|
||||
for (const tag of m.tags) {
|
||||
if (tag !== 'auto-extracted') {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const frequentTags = [...tagCounts.entries()]
|
||||
.filter(([, count]) => count >= 5)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (frequentTags.length > 0) {
|
||||
patterns.push({
|
||||
observation: `反复出现的主题: ${frequentTags.slice(0, 5).map(([tag, count]) => `${tag}(${count}次)`).join(', ')}`,
|
||||
frequency: frequentTags[0][1],
|
||||
sentiment: 'neutral',
|
||||
evidence: frequentTags.map(([tag]) => tag),
|
||||
});
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
// === Improvement Suggestions ===
|
||||
|
||||
private generateImprovements(
|
||||
patterns: PatternObservation[],
|
||||
memories: MemoryEntry[]
|
||||
): ImprovementSuggestion[] {
|
||||
const improvements: ImprovementSuggestion[] = [];
|
||||
|
||||
// Suggestion: Clear pending tasks
|
||||
const taskPattern = patterns.find(p => p.observation.includes('待办任务'));
|
||||
if (taskPattern) {
|
||||
improvements.push({
|
||||
area: '任务管理',
|
||||
suggestion: '清理已完成的任务记忆,对长期未处理的任务降低重要性或标记为已取消',
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
|
||||
// Suggestion: Prune low-importance memories
|
||||
const lowPattern = patterns.find(p => p.observation.includes('低重要性'));
|
||||
if (lowPattern) {
|
||||
improvements.push({
|
||||
area: '记忆管理',
|
||||
suggestion: '执行记忆清理,移除30天以上未访问且重要性低于3的记忆',
|
||||
priority: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Suggestion: User profile enrichment
|
||||
const prefCount = memories.filter(m => m.type === 'preference').length;
|
||||
if (prefCount < 3) {
|
||||
improvements.push({
|
||||
area: '用户理解',
|
||||
suggestion: '主动在对话中了解用户偏好(沟通风格、技术栈、工作习惯),丰富用户画像',
|
||||
priority: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Suggestion: Knowledge consolidation
|
||||
const factCount = memories.filter(m => m.type === 'fact').length;
|
||||
if (factCount > 20) {
|
||||
improvements.push({
|
||||
area: '知识整合',
|
||||
suggestion: '合并相似的事实记忆,提高检索效率。可将相关事实整合为结构化的项目/用户档案',
|
||||
priority: 'low',
|
||||
});
|
||||
}
|
||||
|
||||
return improvements;
|
||||
}
|
||||
|
||||
// === Identity Change Proposals ===
|
||||
|
||||
private proposeIdentityChanges(
|
||||
agentId: string,
|
||||
patterns: PatternObservation[],
|
||||
identityMgr: ReturnType<typeof getAgentIdentityManager>
|
||||
): IdentityChangeProposal[] {
|
||||
const proposals: IdentityChangeProposal[] = [];
|
||||
|
||||
// If many negative patterns, propose instruction update
|
||||
const negativePatterns = patterns.filter(p => p.sentiment === 'negative');
|
||||
if (negativePatterns.length >= 2) {
|
||||
const identity = identityMgr.getIdentity(agentId);
|
||||
const additions = negativePatterns.map(p =>
|
||||
`- 注意: ${p.observation}`
|
||||
).join('\n');
|
||||
|
||||
const proposal = identityMgr.proposeChange(
|
||||
agentId,
|
||||
'instructions',
|
||||
identity.instructions + `\n\n## 自我反思改进\n${additions}`,
|
||||
`基于 ${negativePatterns.length} 个负面模式观察,建议在指令中增加自我改进提醒`
|
||||
);
|
||||
proposals.push(proposal);
|
||||
}
|
||||
|
||||
return proposals;
|
||||
}
|
||||
|
||||
// === History ===
|
||||
|
||||
getHistory(limit: number = 10): ReflectionResult[] {
|
||||
return this.history.slice(-limit);
|
||||
}
|
||||
|
||||
getLastResult(): ReflectionResult | null {
|
||||
return this.history.length > 0 ? this.history[this.history.length - 1] : null;
|
||||
}
|
||||
|
||||
// === Config ===
|
||||
|
||||
getConfig(): ReflectionConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
updateConfig(updates: Partial<ReflectionConfig>): void {
|
||||
this.config = { ...this.config, ...updates };
|
||||
}
|
||||
|
||||
getState(): ReflectionState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
// === Persistence ===
|
||||
|
||||
private loadState(): ReflectionState {
|
||||
try {
|
||||
const raw = localStorage.getItem(REFLECTION_STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* silent */ }
|
||||
return {
|
||||
conversationsSinceReflection: 0,
|
||||
lastReflectionTime: null,
|
||||
lastReflectionAgentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
private saveState(): void {
|
||||
try {
|
||||
localStorage.setItem(REFLECTION_STORAGE_KEY, JSON.stringify(this.state));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
private loadHistory(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(REFLECTION_HISTORY_KEY);
|
||||
if (raw) this.history = JSON.parse(raw);
|
||||
} catch {
|
||||
this.history = [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveHistory(): void {
|
||||
try {
|
||||
localStorage.setItem(REFLECTION_HISTORY_KEY, JSON.stringify(this.history.slice(-10)));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: ReflectionEngine | null = null;
|
||||
|
||||
export function getReflectionEngine(config?: Partial<ReflectionConfig>): ReflectionEngine {
|
||||
if (!_instance) {
|
||||
_instance = new ReflectionEngine(config);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetReflectionEngine(): void {
|
||||
_instance = null;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
|
||||
import { getMemoryManager } from '../lib/agent-memory';
|
||||
import { getAgentIdentityManager } from '../lib/agent-identity';
|
||||
import { getMemoryExtractor } from '../lib/memory-extractor';
|
||||
import { getContextCompactor } from '../lib/context-compactor';
|
||||
import { getReflectionEngine } from '../lib/reflection-engine';
|
||||
|
||||
export interface MessageFile {
|
||||
name: string;
|
||||
@@ -269,8 +274,54 @@ export const useChatStore = create<ChatState>()(
|
||||
const { addMessage, currentAgent, sessionKey } = get();
|
||||
const effectiveSessionKey = sessionKey || `session_${Date.now()}`;
|
||||
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
|
||||
const agentId = currentAgent?.id || 'zclaw-main';
|
||||
|
||||
// Add user message
|
||||
// Check context compaction threshold before adding new message
|
||||
try {
|
||||
const compactor = getContextCompactor();
|
||||
const check = compactor.checkThreshold(get().messages.map(m => ({ role: m.role, content: m.content })));
|
||||
if (check.shouldCompact) {
|
||||
console.log(`[Chat] Context compaction triggered (${check.urgency}): ${check.currentTokens} tokens`);
|
||||
const result = await compactor.compact(
|
||||
get().messages.map(m => ({ role: m.role, content: m.content, id: m.id, timestamp: m.timestamp })),
|
||||
agentId,
|
||||
get().currentConversationId ?? undefined
|
||||
);
|
||||
// Replace messages with compacted version
|
||||
const compactedMsgs: Message[] = result.compactedMessages.map((m, i) => ({
|
||||
id: m.id || `compacted_${i}_${Date.now()}`,
|
||||
role: m.role as Message['role'],
|
||||
content: m.content,
|
||||
timestamp: m.timestamp || new Date(),
|
||||
}));
|
||||
set({ messages: compactedMsgs });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Chat] Context compaction check failed:', err);
|
||||
}
|
||||
|
||||
// Build memory-enhanced content
|
||||
let enhancedContent = content;
|
||||
try {
|
||||
const memoryMgr = getMemoryManager();
|
||||
const identityMgr = getAgentIdentityManager();
|
||||
const relevantMemories = await memoryMgr.search(content, {
|
||||
agentId,
|
||||
limit: 8,
|
||||
minImportance: 3,
|
||||
});
|
||||
const memoryContext = relevantMemories.length > 0
|
||||
? `\n\n## 相关记忆\n${relevantMemories.map(m => `- [${m.type}] ${m.content}`).join('\n')}`
|
||||
: '';
|
||||
const systemPrompt = identityMgr.buildSystemPrompt(agentId, memoryContext);
|
||||
if (systemPrompt) {
|
||||
enhancedContent = `<context>\n${systemPrompt}\n</context>\n\n${content}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Chat] Memory enhancement failed, proceeding without:', err);
|
||||
}
|
||||
|
||||
// Add user message (original content for display)
|
||||
const userMsg: Message = {
|
||||
id: `user_${Date.now()}`,
|
||||
role: 'user',
|
||||
@@ -297,7 +348,7 @@ export const useChatStore = create<ChatState>()(
|
||||
// Try streaming first (OpenFang WebSocket)
|
||||
if (client.getState() === 'connected') {
|
||||
const { runId } = await client.chatStream(
|
||||
content,
|
||||
enhancedContent,
|
||||
{
|
||||
onDelta: (delta: string) => {
|
||||
set((state) => ({
|
||||
@@ -343,6 +394,21 @@ export const useChatStore = create<ChatState>()(
|
||||
m.id === assistantId ? { ...m, streaming: false } : m
|
||||
),
|
||||
}));
|
||||
// Async memory extraction after stream completes
|
||||
const msgs = get().messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
getMemoryExtractor().extractFromConversation(msgs, agentId, get().currentConversationId ?? undefined).catch(err =>
|
||||
console.warn('[Chat] Memory extraction failed:', err)
|
||||
);
|
||||
// Track conversation for reflection trigger
|
||||
const reflectionEngine = getReflectionEngine();
|
||||
reflectionEngine.recordConversation();
|
||||
if (reflectionEngine.shouldReflect()) {
|
||||
reflectionEngine.reflect(agentId).catch(err =>
|
||||
console.warn('[Chat] Reflection failed:', err)
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error: string) => {
|
||||
set((state) => ({
|
||||
@@ -375,7 +441,7 @@ export const useChatStore = create<ChatState>()(
|
||||
}
|
||||
|
||||
// Fallback to REST API (non-streaming)
|
||||
const result = await client.chat(content, {
|
||||
const result = await client.chat(enhancedContent, {
|
||||
sessionKey: effectiveSessionKey,
|
||||
agentId: effectiveAgentId,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user