Files
aiagent/backend/app/core/memdir.py

503 lines
17 KiB
Python
Raw Normal View History

"""
File-based Auto-Memory System MEMORY.md + 4-Type Classification
参考 Claude Code src/memdir/ 设计
- 4 种封闭记忆类型: user / feedback / project / reference
- YAML frontmatter 格式
- MEMORY.md 索引文件
- 陈旧度感知
- 文件系统存储人类可读可版本控制
"""
from __future__ import annotations
import os
import re
import time
import yaml
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# ════════════════════ 记忆类型定义 ════════════════════
class MemoryType(str, Enum):
"""4 种封闭记忆类型 — 参考 Claude Code MEMORY_TYPES"""
USER = "user" # 用户角色/偏好/知识
FEEDBACK = "feedback" # 行为规范(附 Why + How to apply
PROJECT = "project" # 项目上下文/目标/进度
REFERENCE = "reference" # 外部系统指针
# ════════════════════ 数据结构 ════════════════════
@dataclass
class MemoryEntry:
"""单个记忆文件"""
filename: str # 文件名 (不含路径)
filepath: str # 绝对路径
name: str # frontmatter name
description: str # frontmatter description — 用于后续相关性判断
mem_type: MemoryType # frontmatter type
mtime: float # 修改时间 (epoch seconds)
content: str = "" # 正文内容(延迟加载)
@property
def age_days(self) -> int:
"""记忆年龄(天)"""
return max(0, int((time.time() - self.mtime) / 86400))
@property
def age_text(self) -> str:
"""人类可读的年龄描述"""
days = self.age_days
if days == 0:
return "今天"
if days == 1:
return "昨天"
return f"{days} 天前"
@property
def is_stale(self) -> bool:
"""超过 1 天即为陈旧"""
return self.age_days > 1
@property
def staleness_note(self) -> Optional[str]:
"""如果陈旧,返回提醒文本"""
if not self.is_stale:
return None
return (
f"⚠️ 此记忆已保存 {self.age_text}"
"记忆是时间点的快照,不是实时状态 —— 关于代码行为或文件位置的断言可能已过时。"
"请先验证再据此操作。"
)
@dataclass
class MemoryManifest:
"""记忆目录清单"""
entries: List[MemoryEntry] = field(default_factory=list)
index_lines: List[str] = field(default_factory=list) # MEMORY.md 原始行
total_files: int = 0
index_truncated: bool = False # MEMORY.md 是否被截断
# ════════════════════ Frontmatter 解析 ════════════════════
FRONTMATTER_RE = re.compile(r'^---\s*\n([\s\S]*?)---\s*\n?')
MAX_FRONTMATTER_LINES = 30
def parse_frontmatter(text: str) -> Tuple[Dict[str, Any], str]:
"""解析 YAML frontmatter。返回 (frontmatter_dict, body_text)。"""
m = FRONTMATTER_RE.match(text)
if not m:
return {}, text
fm_text = m.group(1)
body = text[m.end():]
try:
fm = yaml.safe_load(fm_text) or {}
except yaml.YAMLError:
logger.debug("Frontmatter YAML 解析失败")
return {}, text
return fm if isinstance(fm, dict) else {}, body
def parse_memory_type(raw: Any) -> Optional[MemoryType]:
"""校验并转换记忆类型为闭集值。"""
if raw is None:
return None
try:
return MemoryType(str(raw).lower())
except ValueError:
return None
# ════════════════════ 提示词模板 ════════════════════
MEMORY_TYPES_PROMPT = """## 记忆类型
你可以将信息保存为以下 4 种类型的记忆文件`.md` 格式 YAML frontmatter
<type>
<name>user</name>
<description>用户角色目标职责和知识好的 user 记忆帮助你为特定用户定制行为</description>
<example>用户是资深 Go 开发者刚接触此项目的 React 前端</example>
</type>
<type>
<name>feedback</name>
<description>用户给出的行为指导 什么该做什么不该做</description>
<body_structure>
以规则本身开头然后是 **Why:** 原因 **How to apply:** 适用范围
</body_structure>
<example>集成测试必须使用真实数据库不要 mockWhy: 上次 mock 通过但生产迁移失败How to apply: 所有涉及 ORM 的测试</example>
</type>
<type>
<name>project</name>
<description>项目上下文 谁在做什么为什么截止时间项目记忆衰减快需要保持更新</description>
<body_structure>
以事实或决策开头然后是 **Why:** 约束截止日期需求方 **How to apply:** 如何影响建议
</body_structure>
<example>周四后冻结所有非关键合并 (2026-03-05)Why: 移动端发布分支切出How to apply: 标记该日期后的 PR 工作</example>
</type>
<type>
<name>reference</name>
<description>外部系统信息指针 Bug 追踪文档监控面板的位置</description>
<example>流水线 Bug 追踪在 Linear 项目 "INGEST" </example>
</type>
## 什么不应该保存为记忆
- 代码模式约定架构 可通过阅读项目代码得出
- Git 历史最近变更 `git log` / `git blame` 是权威来源
- 调试方案修复思路 修复已在代码中commit message 有上下文
- CLAUDE.md 已记录的内容
- 临时任务细节进行中的工作当前对话状态
**这些排除规则即使在用户明确要求保存时也适用** 如果用户要求保存以上内容先询问其中什么是*意外的**非显而易见的*部分
## 何时访问记忆
- 当记忆与当前任务可能相关时
- 用户提及之前的对话或工作
- 用户明确要求检查回忆或记住时
## 信任但验证
- 命名特定函数/文件/标志的记忆是该信息*写入时*存在的主张
- 在据此推荐之前检查文件是否存在grep 函数是否存在
- 当前代码 > 过时记忆"""
MEMORY_SAVE_INSTRUCTIONS = """## 如何保存记忆
保存记忆是两步操作
** 1 ** 将记忆写入独立文件使用以下 frontmatter 格式
```markdown
---
name: {{简短标题}}
description: {{一行描述 用于未来判断相关性尽量具体}}
type: {{user / feedback / project / reference}}
---
{{记忆内容}}
```
文件命名使用语义化英文 slug `user_golang_expert.md`
** 2 ** MEMORY.md 中添加索引行MEMORY.md 是索引每条一行约 150 字符以内
`- [标题](文件名.md) 一行摘要`
- MEMORY.md 超过 200 行后会被截断请保持索引简洁
- 按主题组织不按时间
- 更新或删除错误的记忆
- 写入前先检查是否已有类似记忆可以更新"""
MEMORY_LOAD_INSTRUCTIONS = """## 记忆加载
会话启动时会自动加载 MEMORY.md 索引和相关记忆文件你可以使用文件读取工具查看具体的记忆文件内容
如果用户说 *忽略* *不使用记忆*当作 MEMORY.md 不存在不要提及或引用记忆内容
记忆会随时间的推移而变陈旧如果一个记忆与当前观察代码文件系统冲突以当前观察为准并更新过时的记忆"""
# ════════════════════ 记忆目录管理 ════════════════════
class MemoryDir:
"""
基于文件系统的自动记忆目录
目录结构:
<base_dir>/
MEMORY.md # 索引文件
user_*.md # user 类型记忆
feedback_*.md # feedback 类型记忆
project_*.md # project 类型记忆
reference_*.md # reference 类型记忆
用法:
md = MemoryDir("/path/to/memory")
manifest = md.scan()
prompt = md.build_system_prompt()
md.save_memory("user_golang_expert", MemoryType.USER,
"用户是资深 Go 开发者",
"用户有 10 年 Go 经验,但刚接触 React...")
"""
ENTRYPOINT = "MEMORY.md"
MAX_INDEX_LINES = 200
MAX_INDEX_BYTES = 25_000
MAX_SCAN_FILES = 200
MAX_FRONTMATTER_READ_BYTES = 4096 # 只读前 4KB 用于扫描
def __init__(self, base_dir: str):
self.base_dir = os.path.abspath(base_dir)
os.makedirs(self.base_dir, exist_ok=True)
@property
def index_path(self) -> str:
return os.path.join(self.base_dir, self.ENTRYPOINT)
# ── 扫描 ──
def scan(self, load_content: bool = False) -> MemoryManifest:
"""
扫描记忆目录提取所有 .md 文件的 frontmatter
Returns:
MemoryManifest 包含所有有效记忆条目和索引行
"""
entries: List[MemoryEntry] = []
index_lines: List[str] = []
index_truncated = False
# 读取 MEMORY.md 索引
if os.path.exists(self.index_path):
try:
with open(self.index_path, "r", encoding="utf-8") as f:
raw = f.read(self.MAX_INDEX_BYTES)
all_lines = raw.split("\n")
index_lines = all_lines[:self.MAX_INDEX_LINES]
if len(all_lines) > self.MAX_INDEX_LINES:
index_truncated = True
# 读取是否被截断
if len(raw.encode("utf-8")) >= self.MAX_INDEX_BYTES:
index_truncated = True
except Exception as e:
logger.warning("读取 MEMORY.md 失败: %s", e)
# 扫描所有 .md 文件(排除 MEMORY.md
try:
filenames = sorted(
[f for f in os.listdir(self.base_dir)
if f.endswith(".md") and f != self.ENTRYPOINT],
key=lambda f: os.path.getmtime(os.path.join(self.base_dir, f)),
reverse=True,
)[:self.MAX_SCAN_FILES]
except OSError:
filenames = []
for fn in filenames:
fp = os.path.join(self.base_dir, fn)
try:
mtime = os.path.getmtime(fp)
# 只读 frontmatter 部分
with open(fp, "r", encoding="utf-8") as f:
head = f.read(self.MAX_FRONTMATTER_READ_BYTES)
fm, body = parse_frontmatter(head)
mem_type = parse_memory_type(fm.get("type"))
if mem_type is None:
continue # 跳过无有效类型的文件
name = fm.get("name", fn.replace(".md", "").replace("_", " ").title())
description = fm.get("description", "")
content = ""
if load_content:
# 已读取的 head 可能不完整,重新全量读取
try:
with open(fp, "r", encoding="utf-8") as f:
full = f.read()
_, content = parse_frontmatter(full)
except Exception:
pass
entries.append(MemoryEntry(
filename=fn,
filepath=fp,
name=name,
description=description,
mem_type=mem_type,
mtime=mtime,
content=content,
))
except Exception as e:
logger.debug("扫描记忆文件失败: %s (%s)", fn, e)
return MemoryManifest(
entries=entries,
index_lines=index_lines,
total_files=len(entries),
index_truncated=index_truncated,
)
# ── 加载索引内容 ──
def load_index(self) -> str:
"""读取 MEMORY.md 的完整内容(截断到限制)。"""
if not os.path.exists(self.index_path):
return ""
try:
with open(self.index_path, "r", encoding="utf-8") as f:
raw = f.read(self.MAX_INDEX_BYTES)
lines = raw.split("\n")[:self.MAX_INDEX_LINES]
truncated = "\n".join(lines)
if len(truncated.encode("utf-8")) < len(raw.encode("utf-8")) or len(lines) < len(raw.split("\n")):
truncated += f"\n\n<!-- MEMORY.md 已截断(>{self.MAX_INDEX_LINES} 行或 >{self.MAX_INDEX_BYTES // 1000}KB -->"
return truncated
except Exception as e:
logger.error("读取 MEMORY.md 失败: %s", e)
return ""
# ── 保存 ──
def save_memory(
self,
filename: str,
mem_type: MemoryType,
name: str,
description: str,
content: str,
) -> str:
"""
保存一条记忆
1. 写入 .md 文件 frontmatter
2. 更新 MEMORY.md 索引
Returns:
写入的文件绝对路径
"""
# 安全检查
safe_name = os.path.basename(filename)
if not safe_name.endswith(".md"):
safe_name += ".md"
if safe_name == self.ENTRYPOINT:
raise ValueError(f"不能覆盖 {self.ENTRYPOINT}")
filepath = os.path.join(self.base_dir, safe_name)
# 构建 frontmatter
fm = {
"name": name,
"description": description,
"type": mem_type.value,
}
fm_yaml = yaml.dump(fm, allow_unicode=True, default_flow_style=False).strip()
full_content = f"---\n{fm_yaml}\n---\n\n{content}"
with open(filepath, "w", encoding="utf-8") as f:
f.write(full_content)
logger.info("记忆已保存: %s (type=%s)", safe_name, mem_type.value)
# 更新索引
self._update_index(safe_name, description)
return filepath
def _update_index(self, filename: str, description: str):
"""在 MEMORY.md 中添加或更新索引行。"""
index_line = f"- [{filename.replace('.md', '')}]({filename}) — {description[:100]}"
existing_lines: List[str] = []
if os.path.exists(self.index_path):
try:
with open(self.index_path, "r", encoding="utf-8") as f:
existing_lines = f.read().split("\n")
except Exception:
pass
# 检查是否已有此文件的索引行
updated = False
for i, line in enumerate(existing_lines):
if f"]({filename})" in line:
existing_lines[i] = index_line
updated = True
break
if not updated:
existing_lines.append(index_line)
# 截断
if len(existing_lines) > self.MAX_INDEX_LINES:
existing_lines = existing_lines[-self.MAX_INDEX_LINES:]
logger.warning("MEMORY.md 已截断到 %d", self.MAX_INDEX_LINES)
with open(self.index_path, "w", encoding="utf-8") as f:
f.write("\n".join(existing_lines))
logger.info("MEMORY.md 索引已更新: %s", filename)
def delete_memory(self, filename: str) -> bool:
"""删除一条记忆文件并从索引中移除。"""
safe_name = os.path.basename(filename)
filepath = os.path.join(self.base_dir, safe_name)
if not os.path.exists(filepath):
return False
os.remove(filepath)
logger.info("记忆文件已删除: %s", safe_name)
# 从索引中移除
if os.path.exists(self.index_path):
try:
with open(self.index_path, "r", encoding="utf-8") as f:
lines = f.read().split("\n")
lines = [l for l in lines if f"]({safe_name})" not in l]
with open(self.index_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
except Exception as e:
logger.warning("更新 MEMORY.md 索引失败: %s", e)
return True
# ── 格式化清单 ──
def format_manifest(self, manifest: MemoryManifest) -> str:
"""将记忆清单格式化为 LLM 可用的文本(用于相关性选择)。"""
if not manifest.entries:
return "(无已有记忆)"
lines = []
for e in manifest.entries:
ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(e.mtime))
lines.append(
f"- [{e.mem_type.value}] {e.filename} ({ts}): {e.description or '(无描述)'}"
)
return "\n".join(lines)
# ── 构建系统提示词 ──
def build_system_prompt(self) -> str:
"""
构建注入 system prompt 的记忆模块
包含
1. 记忆类型和保存指导
2. MEMORY.md 索引内容
3. 相关性提醒
"""
parts = [
MEMORY_TYPES_PROMPT,
"",
MEMORY_SAVE_INSTRUCTIONS,
"",
MEMORY_LOAD_INSTRUCTIONS,
]
# 注入 MEMORY.md 内容
index_content = self.load_index()
if index_content:
parts.append("\n## 已有记忆 (MEMORY.md)\n")
parts.append(index_content)
parts.append(f"\n记忆目录: `{self.base_dir}`")
parts.append("(目录已存在,请直接使用)")
return "\n".join(parts)