- 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>
503 lines
17 KiB
Python
503 lines
17 KiB
Python
"""
|
||
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)
|