"""分层记忆系统 — SOUL/USER/MEMORY/DAILY 文件管理.
参考 Hermes/OpenClaw 架构,实现 Agent 人格、用户档案、工作笔记、
日志的持久化存储与 system prompt 注入。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
class MemoryFile:
"""单个记忆文件的管理器,支持 section 级别 CRUD 和容量控制.
文件格式为 Markdown,使用 `## Section` 组织内容::
## 身份
我是小王,一个专业的 AI 助手。
## 性格
友好、耐心、注重细节
"""
def __init__(self, path: Path, char_budget: int | None = None,
protected_sections: set[str] | None = None):
self.path = Path(path)
self.char_budget = char_budget
self._protected_sections = protected_sections or set()
def read(self) -> str:
"""读取整个文件内容,文件不存在返回空字符串."""
if not self.path.exists():
return ""
return self.path.read_text(encoding="utf-8")
def write(self, content: str) -> None:
"""写入内容,自动创建父目录,超容量时自动裁剪.
在内存中完成裁剪后一次性写入,避免中间不一致状态。
"""
self.path.parent.mkdir(parents=True, exist_ok=True)
if self.char_budget and len(content) > self.char_budget:
content = self._trim_content(content, self._protected_sections or None)
self.path.write_text(content, encoding="utf-8")
def read_section(self, name: str) -> str:
"""读取指定 section 的内容(不含标题行)."""
content = self.read()
if not content:
return ""
# 匹配 ## name 后面的内容,直到下一个 ## 或文件末尾
pattern = rf"^## {re.escape(name)}\s*\n(.*?)(?=^## |\Z)"
match = re.search(pattern, content, re.MULTILINE | re.DOTALL)
if match:
return match.group(1).strip()
return ""
def add_section(self, name: str, content: str) -> None:
"""追加内容到指定 section,不存在则创建."""
existing = self.read()
section_content = self.read_section(name)
if section_content:
# 追加到已有 section
old_text = section_content
new_text = f"{old_text}\n{content}"
self.replace_section(name, old_text, new_text)
else:
# 创建新 section
new_section = f"\n## {name}\n{content}"
if existing and not existing.endswith("\n"):
new_section = "\n" + new_section
self.write(existing + new_section)
def replace_section(self, name: str, old_text: str, new_text: str) -> bool:
"""替换 section 内的文本,返回是否成功."""
section_content = self.read_section(name)
if old_text not in section_content:
return False
full_content = self.read()
# 替换 section 内的文本
pattern = rf"(^## {re.escape(name)}\s*\n)(.*?)(?=^## |\Z)"
match = re.search(pattern, full_content, re.MULTILINE | re.DOTALL)
if not match:
return False
original_section_body = match.group(2)
new_section_body = original_section_body.replace(old_text, new_text, 1)
updated = full_content[: match.start(2)] + new_section_body + full_content[match.end(2) :]
self.write(updated)
return True
def remove_section(self, name: str) -> None:
"""删除整个 section(含标题行)."""
content = self.read()
if not content:
return
pattern = rf"^## {re.escape(name)}\s*\n.*?(?=^## |\Z)"
new_content = re.sub(pattern, "", content, flags=re.MULTILINE | re.DOTALL).strip()
self.write(new_content)
def list_sections(self) -> list[str]:
"""列出所有 section 名称."""
content = self.read()
if not content:
return []
return re.findall(r"^## (.+)$", content, re.MULTILINE)
def trim_to_budget(self, protected_sections: set[str] | None = None) -> None:
"""裁剪内容到容量上限,按 section 边界截断.
保持原始 section 顺序,仅从后向前移除非保护 section。
protected_sections 中的 section 始终保留,不参与裁剪。
"""
if not self.char_budget:
return
content = self.read()
if len(content) <= self.char_budget:
return
trimmed = self._trim_content(content, protected_sections)
self.path.write_text(trimmed, encoding="utf-8")
def _trim_content(self, content: str, protected_sections: set[str] | None = None) -> str:
"""在内存中裁剪内容到容量上限,返回裁剪后的字符串(不写文件).
保持原始 section 顺序,仅从后向前移除非保护 section。
"""
if not self.char_budget or len(content) <= self.char_budget:
return content
protected = protected_sections or set()
# 解析所有 section 及其位置
sections: list[tuple[str, int, int]] = [] # (name, start, end)
for match in re.finditer(r"^## (.+)$", content, re.MULTILINE):
name = match.group(1).strip()
start = match.start()
next_match = re.search(r"^## ", content[match.end():], re.MULTILINE)
if next_match:
end = match.end() + next_match.start()
else:
end = len(content)
sections.append((name, start, end))
if not sections:
return content[:self.char_budget]
# 保持原始顺序,标记每个 section 是否受保护
ordered: list[tuple[str, str, bool]] = [] # (name, text, is_protected)
for name, start, end in sections:
ordered.append((name, content[start:end], name in protected))
# 从后向前移除非保护 section,直到总长度在预算内
while ordered:
total = len("\n\n".join(s[1] for s in ordered))
if total <= self.char_budget:
break
# 从后向前找第一个非保护 section 移除
for i in range(len(ordered) - 1, -1, -1):
if not ordered[i][2]:
ordered.pop(i)
break
else:
break # 所有剩余 section 都是受保护的
return "\n\n".join(s[1] for s in ordered).strip()
@dataclass
class MemorySnapshot:
"""一次加载的所有记忆文件快照."""
soul: str = ""
user: str = ""
memory: str = ""
daily: str = ""
total_chars: int = 0
def is_empty(self) -> bool:
return not any([self.soul, self.user, self.memory, self.daily])
# 容量上限常量(字符数)
SOUL_BUDGET = 2000
USER_BUDGET = 1400
MEMORY_BUDGET = 2200
DAILY_BUDGET = 1000 # 每天日志上限
# 默认 SOUL.md 内容
DEFAULT_SOUL = """## 身份
我是 AgentKit,一个专业的 AI 助手。
## 性格
专业、友好、注重细节
## 说话方式
简洁清晰,偶尔使用比喻帮助理解
## 做事准则
- 准确回答用户问题
- 主动记住用户提到的偏好和信息
- 不确定时坦诚说明
"""
class MemoryStore:
"""管理 SOUL/USER/MEMORY/DAILY 四类记忆文件.
存储路径::
base_dir/
├── SOUL.md
├── memories/
│ ├── USER.md
│ ├── MEMORY.md
│ └── daily/
│ ├── 2026-06-07.md
│ └── 2026-06-08.md
"""
def __init__(self, base_dir: Path | str | None = None,
on_change: Callable[[str], None] | None = None):
if base_dir is None:
base_dir = Path.home() / ".agentkit"
self.base_dir = Path(base_dir)
self.base_dir.mkdir(parents=True, exist_ok=True)
self._on_change = on_change
self._base_prompt: str = ""
# 初始化四个 MemoryFile
self._soul = MemoryFile(
self.base_dir / "SOUL.md",
char_budget=SOUL_BUDGET,
protected_sections={"版本", "更新历史"},
)
self._user = MemoryFile(self.base_dir / "memories" / "USER.md", char_budget=USER_BUDGET)
self._memory = MemoryFile(self.base_dir / "memories" / "MEMORY.md", char_budget=MEMORY_BUDGET)
self._daily_dir = self.base_dir / "memories" / "daily"
self._daily_dir.mkdir(parents=True, exist_ok=True)
def get_file(self, file_key: str) -> MemoryFile:
"""获取指定类型的 MemoryFile.
Args:
file_key: "soul" | "user" | "memory" | "daily"
"""
mapping = {
"soul": self._soul,
"user": self._user,
"memory": self._memory,
}
if file_key in mapping:
return mapping[file_key]
if file_key == "daily":
# daily 返回今天的日志文件
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return MemoryFile(self._daily_dir / f"{today}.md", char_budget=DAILY_BUDGET)
raise ValueError(f"Invalid file_key: {file_key}. Must be soul/user/memory/daily")
def ensure_defaults(self) -> None:
"""首次运行时创建默认 SOUL.md."""
if not self._soul.read():
self._soul.write(DEFAULT_SOUL.strip())
def load_all(self) -> MemorySnapshot:
"""加载所有记忆文件."""
soul = self._soul.read()
user = self._user.read()
memory = self._memory.read()
daily = self.load_daily_logs()
total = len(soul) + len(user) + len(memory) + len(daily)
return MemorySnapshot(
soul=soul,
user=user,
memory=memory,
daily=daily,
total_chars=total,
)
def load_daily_logs(self, days: int = 2) -> str:
"""加载最近 N 天的日志."""
parts: list[str] = []
for i in range(days):
from datetime import timedelta
date = datetime.now(timezone.utc) - timedelta(days=i)
filename = f"{date.strftime('%Y-%m-%d')}.md"
daily_file = MemoryFile(self._daily_dir / filename)
content = daily_file.read()
if content:
parts.append(f"### {date.strftime('%Y-%m-%d')}\n{content}")
return "\n\n".join(parts)
def archive_old_dailies(self, keep_days: int = 2) -> int:
"""归档超过 N 天的日志(删除旧文件)."""
from datetime import timedelta
count = 0
cutoff = datetime.now(timezone.utc) - timedelta(days=keep_days)
if not self._daily_dir.exists():
return 0
for f in self._daily_dir.glob("*.md"):
# 从文件名解析日期
try:
date_str = f.stem # e.g. "2026-06-07"
file_date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
if file_date < cutoff:
f.unlink()
count += 1
except ValueError:
continue
return count
def build_system_prompt(self, snapshot: MemorySnapshot, base_prompt: str = "") -> str:
"""将记忆注入 system prompt.
格式::
[SOUL.md content]
[USER.md content]
[MEMORY.md content]
[DAILY.md content]
[base_prompt]
"""
# 保存 base_prompt 供后续刷新使用
if base_prompt:
self._base_prompt = base_prompt
parts: list[str] = []
if snapshot.soul:
parts.append(f"\n{snapshot.soul}\n")
if snapshot.user:
parts.append(f"\n{snapshot.user}\n")
if snapshot.memory:
parts.append(f"\n{snapshot.memory}\n")
if snapshot.daily:
parts.append(f"\n{snapshot.daily}\n")
if base_prompt:
parts.append(base_prompt)
return "\n\n".join(parts) if parts else base_prompt
def refresh_system_prompt(self) -> str:
"""重新加载所有记忆文件并构建 system prompt.
在 MemoryTool 写入记忆后调用,确保 agent 的 _system_prompt
反映最新的记忆内容。
"""
snapshot = self.load_all()
return self.build_system_prompt(snapshot, self._base_prompt)
def notify_change(self) -> None:
"""记忆文件变更后通知回调,刷新所有订阅者的 system prompt."""
if self._on_change is None:
return
try:
new_prompt = self.refresh_system_prompt()
self._on_change(new_prompt)
except Exception:
import logging
logging.getLogger(__name__).warning("Memory notify_change failed", exc_info=True)