Files
aiagent/backend/app/core/memdir.py
renjianbo beff3fac8d fix: delete agent 500 error + dynamic personality + deployment guide
- Fix delete agent 500: clean up FK records (agent_llm_logs, permissions,
  schedules, executions, team_members) and unbind goals/tasks before delete
- Remove hardcoded personality templates in Android, replace with dynamic
  system prompt generation from name + description
- Set promptSectionsEnabled=false to bypass PromptComposer for personality
- Add Tencent Cloud Linux deployment guide (Docker Compose)
- Accumulated backend service updates, frontend UI fixes, Android app changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-29 01:17:21 +08:00

503 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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>集成测试必须使用真实数据库,不要 mock。Why: 上次 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)