diff --git a/core/main_terminal_parts/context/memory.py b/core/main_terminal_parts/context/memory.py index 3c49e283..c124b759 100644 --- a/core/main_terminal_parts/context/memory.py +++ b/core/main_terminal_parts/context/memory.py @@ -1,5 +1,6 @@ import asyncio import json +import re from pathlib import Path from typing import Any, Dict, List, Optional, Set @@ -55,6 +56,7 @@ from modules.easter_egg_manager import EasterEggManager from modules.personalization_manager import ( load_personalization_config, build_personalization_prompt, + resolve_project_memory_inject_limit, RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, @@ -89,11 +91,19 @@ logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True +def _render_optional_block(text: str, tag: str, keep: bool) -> str: + """渲染 [tag]...[/tag] 条件块:keep=True 去掉标记保留内容;keep=False 整块(含内容)删除。""" + pattern = re.compile(r"\[" + re.escape(tag) + r"\](.*?)\[/" + re.escape(tag) + r"\]", re.DOTALL) + if keep: + return pattern.sub(lambda m: m.group(1), text) + return pattern.sub("", text) + + class MemoryMixin: """MainTerminalContextMixin memory 能力 mixin。""" def _scan_project_memories(self): - """扫描 .astrion/memory/*.md 并解析 frontmatter 中的 name 和 description""" + """扫描 .astrion/memory/*.md 并解析 frontmatter 中的 name 和 description,按最近修改时间倒序返回""" try: memory_dir = Path(self.project_path) / WORKSPACE_MEMORY_DIRNAME except Exception: @@ -120,9 +130,12 @@ class MemoryMixin: "file": md_file.name, "name": name or md_file.stem, "description": description or "", + "mtime": md_file.stat().st_mtime, }) except Exception: pass + # 最近修改的排前面;mtime 相同时保持文件名顺序(sorted 基数 + 稳定排序) + results.sort(key=lambda item: item.get("mtime", 0), reverse=True) return results def _build_memory_system_content(self) -> str: @@ -137,10 +150,23 @@ class MemoryMixin: except Exception: global_memory_text = "" + try: + personalization_config = ( + getattr(self.context_manager, "custom_personalization_config", None) + or load_personalization_config(self.data_dir) + ) + except Exception: + personalization_config = None + inject_limit = resolve_project_memory_inject_limit(personalization_config) + project_memories = self._scan_project_memories() - if project_memories: + total_count = len(project_memories) + truncated = inject_limit is not None and total_count > inject_limit + shown_memories = project_memories[:inject_limit] if truncated else project_memories + + if shown_memories: lines = [] - for m in project_memories: + for m in shown_memories: desc = m.get("description", "") if desc: lines.append(f".astrion/memory/{m['file']}:{desc}") @@ -151,16 +177,16 @@ class MemoryMixin: 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}", "") + result = result.replace("{global_memory}", global_memory_text) + result = _render_optional_block(result, "global_memory_empty", keep=not global_memory_text) - 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}", "") + result = result.replace("{project_memory_list}", project_memory_list) + result = _render_optional_block(result, "project_memory_empty", keep=not project_memories) + + if truncated: + result = result.replace("{project_memory_total}", str(total_count)) + result = result.replace("{project_memory_limit}", str(inject_limit)) + result = result.replace("{project_memory_remaining}", str(total_count - inject_limit)) + result = _render_optional_block(result, "project_memory_truncated", keep=truncated) return result.strip() diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 65b3833a..c7278875 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -37,6 +37,9 @@ TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企 RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN = 1 RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX = 30 RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT = 10 +PROJECT_MEMORY_INJECT_LIMIT_MIN = 5 +PROJECT_MEMORY_INJECT_LIMIT_DEFAULT = 20 +PROJECT_MEMORY_INJECT_LIMIT_MAX = 100_000 # 防御性上限;None 表示无上限 DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS = 80_000 DEFAULT_SHALLOW_COMPRESS_KEEP_RECENT_TOOLS = 15 DEFAULT_SHALLOW_COMPRESS_MAX_REPLACE_PER_ROUND = 10 @@ -73,6 +76,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "auto_generate_title": True, "recent_conversations_prompt_enabled": False, "recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, + "project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数:None-无上限 / >=5-该值 "tool_intent_enabled": True, "skill_hints_enabled": False, # Skill 提示系统开关(默认关闭) "skill_strict_terminal_enabled": False, # 强约束:terminal 系列工具需先阅读 terminal-guide @@ -132,6 +136,9 @@ __all__ = [ "RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN", "RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX", "RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT", + "PROJECT_MEMORY_INJECT_LIMIT_MIN", + "PROJECT_MEMORY_INJECT_LIMIT_DEFAULT", + "resolve_project_memory_inject_limit", "load_personalization_config", "save_personalization_config", "ensure_personalization_config", @@ -659,6 +666,12 @@ def sanitize_personalization_payload( else: base["sub_agent_max_turns"] = _sanitize_sub_agent_max_turns(base.get("sub_agent_max_turns")) + # 项目记忆索引最大注入条数(None-无上限 / >=5-该值,默认 20) + if "project_memory_inject_limit" in data: + base["project_memory_inject_limit"] = _sanitize_project_memory_inject_limit(data.get("project_memory_inject_limit")) + else: + base["project_memory_inject_limit"] = _sanitize_project_memory_inject_limit(base.get("project_memory_inject_limit")) + return base @@ -675,6 +688,30 @@ def _sanitize_sub_agent_max_turns(value: Any) -> Optional[int]: return min(parsed, 100_000) # 防御性上限;需要更大时用 0 表示无上限 +def _sanitize_project_memory_inject_limit(value: Any) -> Optional[int]: + """清洗项目记忆索引最大注入条数:None/''/0/负数 → None(无上限);非法值 → 默认 20;正整数钳到 [5, 100000]。""" + if value is None or value == "": + return None + if isinstance(value, bool): + return PROJECT_MEMORY_INJECT_LIMIT_DEFAULT + try: + parsed = int(value) + except (TypeError, ValueError): + return PROJECT_MEMORY_INJECT_LIMIT_DEFAULT + if parsed <= 0: + return None + return max(PROJECT_MEMORY_INJECT_LIMIT_MIN, min(parsed, PROJECT_MEMORY_INJECT_LIMIT_MAX)) + + +def resolve_project_memory_inject_limit(config: Any) -> Optional[int]: + """从个性化配置取项目记忆注入上限:None-无上限;缺键/非法 → 默认 20。""" + if not isinstance(config, dict): + return PROJECT_MEMORY_INJECT_LIMIT_DEFAULT + if "project_memory_inject_limit" not in config: + return PROJECT_MEMORY_INJECT_LIMIT_DEFAULT + return _sanitize_project_memory_inject_limit(config.get("project_memory_inject_limit")) + + def _sanitize_goal_end_conditions(value: Any) -> list: """清洗目标模式结束方式列表,保证至少包含 max_turns。""" cleaned: list = [] diff --git a/prompts/memory_system.txt b/prompts/memory_system.txt index 0e0ed8ae..848cac59 100644 --- a/prompts/memory_system.txt +++ b/prompts/memory_system.txt @@ -6,10 +6,10 @@ - 记忆内容为提示而非事实,执行前应与实际代码验证 ### 总体长期记忆 -{global_memory} -[global_memory_empty]暂无总体长期记忆[/global_memory_empty] +{global_memory}[global_memory_empty]暂无总体长期记忆[/global_memory_empty] ### 项目记忆(.astrion/memory/) -{project_memory_list} -[project_memory_empty]暂无项目记忆[/project_memory_empty] -以上仅为索引(一句话描述)。处理项目相关任务前,用 search_project_memory 检索正文;定位后用 recall_project_memory 读全文。 +[project_memory_truncated]共 {project_memory_total} 条记忆,按最近修改排序,仅注入前 {project_memory_limit} 条索引: +[/project_memory_truncated]{project_memory_list}[project_memory_empty]暂无项目记忆[/project_memory_empty] +[project_memory_truncated]其余 {project_memory_remaining} 条未注入(可用 search_project_memory 按关键词检索)。 +[/project_memory_truncated]以上仅为索引(一句话描述)。处理项目相关任务前,用 search_project_memory 检索正文;定位后用 recall_project_memory 读全文。 diff --git a/server/chat/settings.py b/server/chat/settings.py index 6a4977a2..efde868b 100644 --- a/server/chat/settings.py +++ b/server/chat/settings.py @@ -22,6 +22,7 @@ from modules.personalization_manager import ( save_personalization_config, RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, + PROJECT_MEMORY_INJECT_LIMIT_MIN, ) from modules.skills_manager import ( get_skills_catalog, @@ -286,6 +287,10 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace "recent_conversations_prompt_limit_range": { "min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, "max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, + }, + "project_memory_inject_limit_range": { + "min": PROJECT_MEMORY_INJECT_LIMIT_MIN, + "max": None, } }) except Exception as exc: @@ -372,6 +377,10 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp "recent_conversations_prompt_limit_range": { "min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, "max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, + }, + "project_memory_inject_limit_range": { + "min": PROJECT_MEMORY_INJECT_LIMIT_MIN, + "max": None, } }) except ValueError as exc: diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 7acf5296..a7511fa5 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -1087,6 +1087,21 @@ +