feat(memory): 新增项目记忆系统,整合总体+项目记忆注入

- 新建 .agents/memory/ 项目记忆目录,支持 YAML frontmatter + Markdown
- 新增 update_project_memory / recall_project_memory 工具(复用 write_file/read_file)
- 新建 memory_system.txt prompt,总体长期记忆 + 项目记忆统一注入
- 记忆从 workspace_system.txt 中移出,独立为 memory_prompt 注入
- 重排 system prompt 注入顺序:主→权限→环境→对话→个性化→工作区→AGENTS.md→skills→记忆→禁用
- 压缩后选择性 invalidate 4 个 frozen keys(skills/workspace/personalization/memory)
- 前端:notebook/notebook-pen 图标、动画、状态文案、展开内容渲染
- 优化三个记忆工具的 description 和 memory_system prompt,引导模型主动调用
This commit is contained in:
JOJO 2026-06-10 15:14:01 +08:00
parent 5d2da692b0
commit 820ec69354
14 changed files with 332 additions and 46 deletions

View File

@ -225,6 +225,79 @@ class MainTerminalContextMixin:
# 构建上下文
return self.context_manager.build_main_context(memory)
def _scan_project_memories(self):
"""扫描 .agents/memory/*.md 并解析 frontmatter 中的 name 和 description"""
try:
memory_dir = Path(self.project_path) / ".agents" / "memory"
except Exception:
return []
if not memory_dir.exists() or not memory_dir.is_dir():
return []
results = []
for md_file in sorted(memory_dir.glob("*.md")):
try:
text = md_file.read_text(encoding="utf-8")
name = None
description = None
if text.startswith("---"):
end = text.find("---", 3)
if end > 0:
frontmatter = text[3:end]
for line in frontmatter.strip().split("\n"):
line_stripped = line.strip()
if line_stripped.startswith("name:"):
name = line_stripped.split(":", 1)[1].strip()
elif line_stripped.startswith("description:"):
description = line_stripped.split(":", 1)[1].strip()
results.append({
"file": md_file.name,
"name": name or md_file.stem,
"description": description or "",
})
except Exception:
pass
return results
def _build_memory_system_content(self) -> str:
"""构建记忆系统的 prompt 内容"""
template = self.load_prompt("memory_system").strip()
if not template:
return ""
try:
global_memory = self.memory_manager.read_main_memory()
global_memory_text = global_memory.strip() if global_memory else ""
except Exception:
global_memory_text = ""
project_memories = self._scan_project_memories()
if project_memories:
lines = []
for m in project_memories:
desc = m.get("description", "")
if desc:
lines.append(f".agents/memory/{m['file']}{desc}")
else:
lines.append(f".agents/memory/{m['file']}")
project_memory_list = "\n".join(lines)
else:
project_memory_list = ""
result = template
if global_memory_text:
result = result.replace("{global_memory}", global_memory_text)
result = result.replace("[global_memory_empty]", "").replace("[/global_memory_empty]", "")
else:
result = result.replace("{global_memory}", "")
if project_memory_list:
result = result.replace("{project_memory_list}", project_memory_list)
result = result.replace("[project_memory_empty]", "").replace("[/project_memory_empty]", "")
else:
result = result.replace("{project_memory_list}", "")
return result.strip()
def _build_recent_conversations_message(self, limit: int = 10) -> Optional[str]:
"""构建最近对话提示(仅当前工作区)。"""
try:
@ -318,7 +391,6 @@ class MainTerminalContextMixin:
container_memory=container_memory,
project_storage=project_storage,
file_tree=context["project_info"]["file_tree"],
memory=context["memory"],
current_time=datetime.now().strftime("%Y-%m-%d %H"),
model_description=prompt_replacements.get("model_description", "")
)
@ -334,31 +406,26 @@ class MainTerminalContextMixin:
personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False
skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir))
enabled_skills = merge_enabled_skills(
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
skills_catalog,
personalization_config.get("skills_catalog_snapshot") if isinstance(personalization_config, dict) else None,
)
def _build_skills_system_prompt() -> str:
skills_template = self.load_prompt("skills_system").strip()
skills_list = build_skills_list(skills_catalog, enabled_skills)
return build_skills_prompt(skills_template, skills_list) or ""
skills_prompt = self._get_or_init_frozen_prompt(
"frozen_skills_prompt",
_build_skills_system_prompt,
)
if skills_prompt:
messages.append({"role": "system", "content": skills_prompt})
# 顺序主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → skills → 记忆 → 自定义 → 禁用提示
workspace_system = self._get_or_init_frozen_prompt(
"frozen_workspace_prompt",
lambda: self.context_manager._build_workspace_system_message(context) or "",
# 权限模式
permission_mode_message = self._get_or_init_frozen_mode_prompt(
"frozen_permission_prompt",
self._build_permission_mode_message,
)
if workspace_system:
messages.append({"role": "system", "content": workspace_system})
if permission_mode_message:
messages.append({"role": "system", "content": permission_mode_message})
# 执行环境
execution_mode_message = self._get_or_init_frozen_mode_prompt(
"frozen_execution_prompt",
self._build_execution_mode_message,
)
if execution_mode_message:
messages.append({"role": "system", "content": execution_mode_message})
# 最近对话
def _build_recent_conversations_prompt() -> str:
recent_conversations_enabled = (
bool(personalization_config.get("recent_conversations_prompt_enabled", False))
@ -389,21 +456,7 @@ class MainTerminalContextMixin:
if recent_conversations_prompt:
messages.append({"role": "system", "content": recent_conversations_prompt})
# 注入权限模式说明(根据当前模式动态生成)
permission_mode_message = self._get_or_init_frozen_mode_prompt(
"frozen_permission_prompt",
self._build_permission_mode_message,
)
if permission_mode_message:
messages.append({"role": "system", "content": permission_mode_message})
execution_mode_message = self._get_or_init_frozen_mode_prompt(
"frozen_execution_prompt",
self._build_execution_mode_message,
)
if execution_mode_message:
messages.append({"role": "system", "content": execution_mode_message})
# 支持按对话覆盖的个性化配置
# 个性化配置
def _build_personalization_system_prompt() -> str:
personalization_block = build_personalization_prompt(personalization_config, include_header=False)
if not personalization_block:
@ -422,7 +475,15 @@ class MainTerminalContextMixin:
if personalization_text:
messages.append({"role": "system", "content": personalization_text})
# AGENTS.md 自动注入
# 工作区信息
workspace_system = self._get_or_init_frozen_prompt(
"frozen_workspace_prompt",
lambda: self.context_manager._build_workspace_system_message(context) or "",
)
if workspace_system:
messages.append({"role": "system", "content": workspace_system})
# AGENTS.md
def _build_agents_md_prompt() -> str:
agents_md_inject_enabled = bool(personalization_config.get("agents_md_auto_inject", False)) if isinstance(personalization_config, dict) else False
if not agents_md_inject_enabled:
@ -442,8 +503,37 @@ class MainTerminalContextMixin:
if agents_md_text:
messages.append({"role": "system", "content": agents_md_text})
# 支持按对话覆盖的自定义 system promptAPI 用途)。
# 放在最后一个 system 消息位置,确保优先级最高,便于业务场景强约束。
# skills 列表
skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir))
enabled_skills = merge_enabled_skills(
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
skills_catalog,
personalization_config.get("skills_catalog_snapshot") if isinstance(personalization_config, dict) else None,
)
def _build_skills_system_prompt() -> str:
skills_template = self.load_prompt("skills_system").strip()
skills_list = build_skills_list(skills_catalog, enabled_skills)
return build_skills_prompt(skills_template, skills_list) or ""
skills_prompt = self._get_or_init_frozen_prompt(
"frozen_skills_prompt",
_build_skills_system_prompt,
)
if skills_prompt:
messages.append({"role": "system", "content": skills_prompt})
# 记忆系统(总体长期记忆 + 项目记忆)
def _build_memory_system_prompt() -> str:
return self._build_memory_system_content()
memory_prompt = self._get_or_init_frozen_prompt(
"frozen_memory_prompt",
_build_memory_system_prompt,
)
if memory_prompt:
messages.append({"role": "system", "content": memory_prompt})
# API 自定义 system prompt
custom_system_prompt = self._get_or_init_frozen_prompt(
"frozen_custom_system_prompt",
lambda: (
@ -455,7 +545,7 @@ class MainTerminalContextMixin:
if custom_system_prompt:
messages.append({"role": "system", "content": custom_system_prompt})
# 禁用工具提示也作为“开头 system prompt”注入便于后续统一合并。
# 禁用工具提示
disabled_notice = self._get_or_init_frozen_prompt(
"frozen_disabled_tools_prompt",
lambda: self._format_disabled_tool_notice() or "",

View File

@ -835,7 +835,7 @@ class MainTerminalToolsDefinitionMixin:
"type": "function",
"function": {
"name": "update_memory",
"description": "按条目更新记忆列表自动编号。append 追加新条目replace 用序号替换delete 用序号删除。应积极记录长期偏好、项目事实、已验证经验、用户明确要求记住的内容;不要记录密钥、隐私或未确认猜测",
"description": "按条目管理总体长期记忆自动编号跨项目通用。append/replace/delete。当用户提到个人信息、偏好、跨项目习惯或用户主动要求「记住xxx」时应积极主动地调用本工具。不记录密钥、隐私、未确认猜测。与特定项目绑定的技术约定用 update_project_memory",
"parameters": {
"type": "object",
"properties": self._inject_intent({
@ -847,6 +847,48 @@ class MainTerminalToolsDefinitionMixin:
}
}
},
{
"type": "function",
"function": {
"name": "recall_project_memory",
"description": "读取指定的项目记忆文件,返回完整内容(含 frontmatter。项目记忆存储在 .agents/memory/ 目录下。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"name": {
"type": "string",
"description": "记忆名称,对应 .agents/memory/{name}.md 文件名"
}
}),
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "update_project_memory",
"description": "创建或覆盖项目记忆文件。当你发现有关当前项目的重要约定/决策/坑、用户表现出对项目的偏好或用户主动要求「在当前项目里下次要xxx/记住xxx」时应积极主动地调用本工具。记忆名称用英文下划线描述格式为'当xxxx时应该索引本记忆'。不记录可从代码直接推断的或一次性信息。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"name": {
"type": "string",
"description": "记忆名称,同时作为文件名 .agents/memory/{name}.md。建议英文+下划线,如 docker_compose"
},
"description": {
"type": "string",
"description": "记忆简述,格式为'当xxxx时应该索引本记忆',用于在 prompt 中作为索引展示≤100字"
},
"content": {
"type": "string",
"description": "记忆正文Markdown记录具体内容"
}
}),
"required": ["name", "description", "content"]
}
}
},
{
"type": "function",
"function": {

View File

@ -185,6 +185,8 @@ class MainTerminalToolsExecutionMixin:
"vlm_analyze",
"ocr_image",
"update_memory",
"recall_project_memory",
"update_project_memory",
"conversation_search",
"conversation_review",
"todo_create",
@ -535,6 +537,35 @@ class MainTerminalToolsExecutionMixin:
result.pop("target_dir", None)
return result
def _handle_update_project_memory(self, name: str, description: str, content: str) -> Dict[str, Any]:
"""处理 update_project_memory写入 .agents/memory/{name}.md"""
safe_name = str(name).strip()
if not safe_name or "/" in safe_name or "\\" in safe_name:
return {"success": False, "error": f"记忆名称不合法: {name}"}
# 确保 .agents/memory/ 目录存在
memory_dir = Path(self.project_path) / ".agents" / "memory"
try:
memory_dir.mkdir(parents=True, exist_ok=True)
except Exception as exc:
return {"success": False, "error": f"创建记忆目录失败: {exc}"}
# 拼接完整文件内容YAML frontmatter + markdown
full_content = f"---\nname: {safe_name}\ndescription: {description}\n---\n\n{content}\n"
file_path = memory_dir / f"{safe_name}.md"
try:
file_path.write_text(full_content, encoding="utf-8")
except Exception as exc:
return {"success": False, "error": f"写入记忆文件失败: {exc}"}
return {
"success": True,
"memory_name": safe_name,
"path": str(file_path),
"summary": f"{'更新' if file_path.exists() else '创建'}项目记忆: {safe_name}",
}
def _mark_skill_read_from_result(self, tool_name: str, arguments: Dict[str, Any], result: Dict[str, Any]) -> None:
if tool_name not in {"read_file", "read_skill"}:
return
@ -1504,6 +1535,26 @@ class MainTerminalToolsExecutionMixin:
index=index
)
elif tool_name == "recall_project_memory":
name = str(arguments.get("name", "")).strip()
if not name:
result = {"success": False, "error": "recall_project_memory 需要 name 参数"}
else:
result = self._handle_recall_project_memory(name)
elif tool_name == "update_project_memory":
name = str(arguments.get("name", "")).strip()
description = str(arguments.get("description", "")).strip()
content_text = str(arguments.get("content", "")).strip()
if not name:
result = {"success": False, "error": "update_project_memory 需要 name 参数"}
elif not description:
result = {"success": False, "error": "update_project_memory 需要 description 参数"}
elif not content_text:
result = {"success": False, "error": "update_project_memory 需要 content 参数"}
else:
result = self._handle_update_project_memory(name, description, content_text)
elif tool_name == "conversation_search":
query = str(arguments.get("query") or "").strip()
raw_keywords = arguments.get("keywords")

View File

@ -158,6 +158,21 @@ class MainTerminalToolsReadMixin:
result["skill_name"] = skill_name
return result
def _handle_recall_project_memory(self, name: str) -> Dict:
"""处理 recall_project_memory读取 .agents/memory/{name}.md"""
safe_name = str(name).strip()
if not safe_name or "/" in safe_name or "\\" in safe_name:
return {"success": False, "error": f"记忆名称不合法: {name}"}
file_path = f".agents/memory/{safe_name}.md"
read_args = {
"path": file_path,
"type": "read",
}
result = self._handle_read_tool(read_args)
if result.get("success"):
result["memory_name"] = safe_name
return result
@staticmethod
def _clamp_int(value, default, min_value=None, max_value=None):
"""将输入转换为整数并限制范围。"""

14
prompts/memory_system.txt Normal file
View File

@ -0,0 +1,14 @@
项目记忆系统已启用。总体长期记忆来自用户的全局记录,项目记忆以文件形式存放在 .agents/memory/ 下。
- 开始涉及项目约定、历史决策或已知问题的任务前,先检查项目记忆索引,如有相关记忆则用 recall_project_memory 读取
- 当你发现有关当前项目的重要约定/决策/调试坑、用户表现出对项目的偏好,或用户说「在这个项目里下次/记住」时,用 update_project_memory 主动记录
- 当用户提到个人信息、偏好或说「记住xxx」时用 update_memory 记录为总体长期记忆
- 记忆内容为提示而非事实,执行前应与实际代码验证
### 总体长期记忆
{global_memory}
[global_memory_empty]暂无总体长期记忆[/global_memory_empty]
### 项目记忆(.agents/memory/
{project_memory_list}
[project_memory_empty]暂无项目记忆[/project_memory_empty]

View File

@ -9,5 +9,3 @@
```
{file_tree}
```
- **长期记忆**{memory}

View File

@ -457,6 +457,13 @@ async def run_deep_compression(
)
# 更新对话 metadata压缩记录 + 清理压缩状态标记(同一对话,无切换)。
# 同时清除需要重建的 frozen prompt 缓存,使压缩后下一次请求自动重新加载动态内容。
REBUILD_FROZEN_KEYS = (
"frozen_skills_prompt",
"frozen_workspace_prompt",
"frozen_personalization_prompt",
"frozen_memory_prompt",
)
meta_updates = {
"compression_count": target_count,
"deep_compression_records": all_records,
@ -469,10 +476,14 @@ async def run_deep_compression(
"compression_resume_payload": None,
"is_ultra_long_conversation": False,
}
for frozen_key in REBUILD_FROZEN_KEYS:
meta_updates[frozen_key] = None
cm.conversation_manager.update_conversation_metadata(conversation_id, meta_updates)
# 同步内存中的 metadata保证后续在同一对话内的判断读到最新值。
# 同步内存中的 metadata清除 frozen 缓存
try:
if getattr(cm, "current_conversation_id", None) == conversation_id and isinstance(cm.conversation_metadata, dict):
for frozen_key in REBUILD_FROZEN_KEYS:
cm.conversation_metadata.pop(frozen_key, None)
cm.conversation_metadata.update(meta_updates)
except Exception:
pass

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4M2 6h4m-4 4h4m-4 4h4m-4 4h4"/><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/></svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M2 6h4m-4 4h4m-4 4h4m-4 4h4"/><rect width="16" height="20" x="4" y="2" rx="2"/><path d="M16 2v20"/></svg>

After

Width:  |  Height:  |  Size: 309 B

View File

@ -100,6 +100,10 @@ export function renderEnhancedToolResult(
// 记忆类
else if (name === 'update_memory') {
return renderUpdateMemory(result, args);
} else if (name === 'recall_project_memory') {
return renderRecallProjectMemory(result, args);
} else if (name === 'update_project_memory') {
return renderUpdateProjectMemory(result, args);
} else if (name === 'conversation_search') {
return renderConversationSearch(result, args);
} else if (name === 'conversation_review') {
@ -704,6 +708,54 @@ function renderUpdateMemory(result: any, args: any): string {
return html;
}
function renderRecallProjectMemory(result: any, args: any): string {
const name = args.name || result.memory_name || '';
const status = formatToolStatusLabel(result, '✓ 已读取');
const content = result.content || result.text || '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>记忆:</strong>${escapeHtml(name)}</div>`;
html += '</div>';
if (result.success && content) {
html += '<div class="tool-result-content scrollable">';
html += `<pre>${escapeHtml(content)}</pre>`;
html += '</div>';
} else if (!result.success && result.error) {
html += `<div class="tool-result-error">${escapeHtml(String(result.error))}</div>`;
}
return html;
}
function renderUpdateProjectMemory(result: any, args: any): string {
const name = args.name || result.memory_name || '';
const description = args.description || '';
const content = args.content || '';
const status = formatToolStatusLabel(result, '✓ 已更新');
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>记忆:</strong>${escapeHtml(name)}</div>`;
if (description) {
html += `<div><strong>描述:</strong>${escapeHtml(description)}</div>`;
}
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
html += '</div>';
if (result.success && content) {
html += '<div class="tool-result-content scrollable">';
html += '<div class="content-label">记忆内容:</div>';
html += `<pre>${escapeHtml(content)}</pre>`;
html += '</div>';
}
return html;
}
function renderConversationSearch(result: any, args: any): string {
const results = Array.isArray(result?.results) ? result.results : [];
const keywords = Array.isArray(result?.keywords)

View File

@ -113,6 +113,8 @@ const TOOL_SCENE_MAP: Record<string, string> = {
terminal_snapshot: 'terminalSnapshot',
sleep: 'terminalSleep',
update_memory: 'memoryUpdate',
recall_project_memory: 'reader',
update_project_memory: 'memoryUpdate',
todo_create: 'todoCreate',
todo_update_task: 'todoUpdate',
todo_delete_task: 'todoDelete',

View File

@ -18,6 +18,8 @@ const RUNNING_ANIMATIONS: Record<string, string> = {
vlm_analyze: 'file-animation',
run_command: 'terminal-animation',
update_memory: 'memory-animation',
recall_project_memory: 'read-animation',
update_project_memory: 'memory-animation',
sleep: 'wait-animation',
terminal_session: 'terminal-animation',
terminal_input: 'terminal-animation',
@ -41,6 +43,8 @@ const RUNNING_STATUS_TEXTS: Record<string, string> = {
save_webpage: '正在保存网页...',
run_command: '调用 run_command',
update_memory: '正在更新记忆...',
recall_project_memory: '正在回顾项目记忆...',
update_project_memory: '正在更新项目记忆...',
terminal_session: '正在管理终端会话...',
terminal_input: '调用 terminal_input',
terminal_snapshot: '正在获取终端快照...',
@ -63,6 +67,8 @@ const COMPLETED_STATUS_TEXTS: Record<string, string> = {
vlm_analyze: '图片解析完成',
run_command: '命令执行完成',
update_memory: '记忆更新成功',
recall_project_memory: '项目记忆已读取',
update_project_memory: '项目记忆已更新',
terminal_session: '终端操作完成',
terminal_input: '终端输入完成',
terminal_snapshot: '终端快照已返回',

View File

@ -29,6 +29,8 @@ export const ICONS = Object.freeze({
mcpLogo: '/static/icons/mcp-logo.svg',
monitor: '/static/icons/monitor.svg',
navigation: '/static/icons/navigation.svg',
notebook: '/static/icons/notebook.svg',
notebookPen: '/static/icons/notebook-pen.svg',
octagon: '/static/icons/octagon.svg',
pencil: '/static/icons/pencil.svg',
python: '/static/icons/python.svg',
@ -77,6 +79,8 @@ export const TOOL_ICON_MAP = Object.freeze({
terminal_snapshot: 'clipboard',
unfocus_file: 'eye',
update_memory: 'brain',
recall_project_memory: 'notebook',
update_project_memory: 'notebookPen',
conversation_search: 'search',
conversation_review: 'book',
get_sub_agent_status: 'bot',

View File

@ -2331,7 +2331,6 @@ class ContextManager:
if context["project_info"].get("file_tree")
else ""
),
memory=context["memory"],
)
return content