From 56c067b71079bb4c902c0531a173e59594c5ab76 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 12 Aug 2026 23:07:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20=E6=96=B0=E5=A2=9E=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=AE=B0=E5=BF=86=E6=9C=80=E5=A4=A7=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E6=9D=A1=E6=95=B0=E8=AE=BE=E7=BD=AE=EF=BC=8C=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E6=8C=89=E6=9C=80=E8=BF=91=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 个人空间-上下文新增「最大记忆注入」(默认 20,最小 5,留空无上限) - 提示词中项目记忆索引从文件名字典序改为按 mtime 倒序注入 - 超上限时截断并注入说明:共 N 条、仅注入前 x 条、其余用 search_project_memory 检索 - 修复 memory_system 模板条件块只删标记不删占位文字导致的「暂无xx记忆」残留 --- core/main_terminal_parts/context/memory.py | 52 ++++++++++++----- modules/personalization_manager.py | 37 ++++++++++++ prompts/memory_system.txt | 10 ++-- server/chat/settings.py | 9 +++ .../personalization/PersonalizationDrawer.vue | 42 ++++++++++++++ static/src/stores/personalization.ts | 56 +++++++++++++++++++ 6 files changed, 188 insertions(+), 18 deletions(-) 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 @@ +
+ 最大记忆注入 +
+ +
+
上下文压缩策略 { personalization.setRecentConversationsPromptLimit(null); }; +const handleProjectMemoryInjectLimitInput = (event: Event) => { + const target = event.target as HTMLInputElement | null; + if (!target) { + return; + } + personalization.updateField({ + key: 'project_memory_inject_limit', + value: target.value + }); +}; + +const commitProjectMemoryInjectLimitInput = (event: Event) => { + const target = event.target as HTMLInputElement | null; + if (!target || !target.value) { + // 留空 = 无上限 + personalization.setProjectMemoryInjectLimit(null); + return; + } + const parsed = Number(target.value); + personalization.setProjectMemoryInjectLimit(Number.isNaN(parsed) ? null : parsed); +}; + +const restoreProjectMemoryInjectLimit = () => { + personalization.restoreProjectMemoryInjectLimit(); +}; + const handleCompressionNumberInput = (key: CompressionField, event: Event) => { const target = event.target as HTMLInputElement | null; if (!target || !target.value) { diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index 09e763bc..5c756b4b 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -17,6 +17,7 @@ interface PersonalForm { auto_generate_title: boolean; recent_conversations_prompt_enabled: boolean; recent_conversations_prompt_limit: number | string; + project_memory_inject_limit: number | string | null; tool_intent_enabled: boolean; skill_hints_enabled: boolean; skill_strict_terminal_enabled: boolean; @@ -94,11 +95,14 @@ interface PersonalizationState { toolCategories: Array<{ id: string; label: string }>; skillsCatalog: Array<{ id: string; label: string; description?: string }>; recentConversationsPromptLimitRange: { min: number; max: number }; + projectMemoryInjectLimitMin: number; experiments: ExperimentState; } const DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT = 10; const DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE = { min: 1, max: 30 }; +const DEFAULT_PROJECT_MEMORY_INJECT_LIMIT = 20; +const PROJECT_MEMORY_INJECT_LIMIT_MIN = 5; const DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS = 80000; const DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS = 150000; const RUN_MODE_OPTIONS: RunMode[] = ['fast', 'thinking']; @@ -215,6 +219,7 @@ const defaultForm = (): PersonalForm => ({ auto_generate_title: true, recent_conversations_prompt_enabled: false, recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT, + project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT, tool_intent_enabled: true, skill_hints_enabled: false, skill_strict_terminal_enabled: false, @@ -338,6 +343,7 @@ export const usePersonalizationStore = defineStore('personalization', { toolCategories: [], skillsCatalog: [], recentConversationsPromptLimitRange: { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE }, + projectMemoryInjectLimitMin: PROJECT_MEMORY_INJECT_LIMIT_MIN, experiments: loadExperimentState() }), actions: { @@ -419,6 +425,9 @@ export const usePersonalizationStore = defineStore('personalization', { recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit( data.recent_conversations_prompt_limit ), + project_memory_inject_limit: this.normalizeProjectMemoryInjectLimit( + data.project_memory_inject_limit + ), tool_intent_enabled: !!data.tool_intent_enabled, skill_hints_enabled: !!data.skill_hints_enabled, skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled, @@ -604,6 +613,25 @@ export const usePersonalizationStore = defineStore('personalization', { } return Math.max(min, Math.min(max, Math.round(parsed))); }, + normalizeProjectMemoryInjectLimit(value: any): number | null { + // undefined(老接口未下发)/非法值 → 默认 20;null/''/0/负数 → null(无上限);正整数钳到 >= min + if (typeof value === 'undefined') { + return DEFAULT_PROJECT_MEMORY_INJECT_LIMIT; + } + if (value === null || value === '') { + return null; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return DEFAULT_PROJECT_MEMORY_INJECT_LIMIT; + } + const rounded = Math.round(parsed); + if (rounded <= 0) { + return null; + } + const min = this.projectMemoryInjectLimitMin ?? PROJECT_MEMORY_INJECT_LIMIT_MIN; + return Math.max(min, rounded); + }, applyPersonalizationMeta(payload: any) { if (payload && payload.recent_conversations_prompt_limit_range) { const { min, max } = payload.recent_conversations_prompt_limit_range; @@ -616,6 +644,13 @@ export const usePersonalizationStore = defineStore('personalization', { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE }; } + if (payload && payload.project_memory_inject_limit_range) { + const { min } = payload.project_memory_inject_limit_range; + this.projectMemoryInjectLimitMin = + typeof min === 'number' ? min : PROJECT_MEMORY_INJECT_LIMIT_MIN; + } else { + this.projectMemoryInjectLimitMin = PROJECT_MEMORY_INJECT_LIMIT_MIN; + } if (payload && Array.isArray(payload.tool_categories)) { this.toolCategories = payload.tool_categories .map((item: { id?: string; label?: string } = {}) => ({ @@ -786,6 +821,27 @@ export const usePersonalizationStore = defineStore('personalization', { this.clearFeedback(); this.scheduleAutoSave(); }, + setProjectMemoryInjectLimit(value: number | null) { + // null = 无上限 + const target = + value === null || typeof value === 'undefined' + ? null + : this.normalizeProjectMemoryInjectLimit(value); + this.form = { + ...this.form, + project_memory_inject_limit: target + }; + this.clearFeedback(); + this.scheduleAutoSave(); + }, + restoreProjectMemoryInjectLimit() { + this.form = { + ...this.form, + project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT + }; + this.clearFeedback(); + this.scheduleAutoSave(); + }, toggleDefaultToolCategory(categoryId: string) { if (!categoryId) { return;