feat(memory): 新增项目记忆最大注入条数设置,记忆索引按最近修改排序

- 个人空间-上下文新增「最大记忆注入」(默认 20,最小 5,留空无上限)
- 提示词中项目记忆索引从文件名字典序改为按 mtime 倒序注入
- 超上限时截断并注入说明:共 N 条、仅注入前 x 条、其余用 search_project_memory 检索
- 修复 memory_system 模板条件块只删标记不删占位文字导致的「暂无xx记忆」残留
This commit is contained in:
JOJO 2026-08-12 23:07:14 +08:00
parent 16900b0a20
commit 56c067b710
6 changed files with 188 additions and 18 deletions

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import re
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set
@ -55,6 +56,7 @@ from modules.easter_egg_manager import EasterEggManager
from modules.personalization_manager import ( from modules.personalization_manager import (
load_personalization_config, load_personalization_config,
build_personalization_prompt, build_personalization_prompt,
resolve_project_memory_inject_limit,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
@ -89,11 +91,19 @@ logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True 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: class MemoryMixin:
"""MainTerminalContextMixin memory 能力 mixin。""" """MainTerminalContextMixin memory 能力 mixin。"""
def _scan_project_memories(self): def _scan_project_memories(self):
"""扫描 .astrion/memory/*.md 并解析 frontmatter 中的 name 和 description""" """扫描 .astrion/memory/*.md 并解析 frontmatter 中的 name 和 description,按最近修改时间倒序返回"""
try: try:
memory_dir = Path(self.project_path) / WORKSPACE_MEMORY_DIRNAME memory_dir = Path(self.project_path) / WORKSPACE_MEMORY_DIRNAME
except Exception: except Exception:
@ -120,9 +130,12 @@ class MemoryMixin:
"file": md_file.name, "file": md_file.name,
"name": name or md_file.stem, "name": name or md_file.stem,
"description": description or "", "description": description or "",
"mtime": md_file.stat().st_mtime,
}) })
except Exception: except Exception:
pass pass
# 最近修改的排前面mtime 相同时保持文件名顺序sorted 基数 + 稳定排序)
results.sort(key=lambda item: item.get("mtime", 0), reverse=True)
return results return results
def _build_memory_system_content(self) -> str: def _build_memory_system_content(self) -> str:
@ -137,10 +150,23 @@ class MemoryMixin:
except Exception: except Exception:
global_memory_text = "" 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() 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 = [] lines = []
for m in project_memories: for m in shown_memories:
desc = m.get("description", "") desc = m.get("description", "")
if desc: if desc:
lines.append(f".astrion/memory/{m['file']}{desc}") lines.append(f".astrion/memory/{m['file']}{desc}")
@ -151,16 +177,16 @@ class MemoryMixin:
project_memory_list = "" project_memory_list = ""
result = template result = template
if global_memory_text: result = result.replace("{global_memory}", global_memory_text)
result = result.replace("{global_memory}", global_memory_text) result = _render_optional_block(result, "global_memory_empty", keep=not 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_list}", project_memory_list) result = _render_optional_block(result, "project_memory_empty", keep=not project_memories)
result = result.replace("[project_memory_empty]", "").replace("[/project_memory_empty]", "")
else: if truncated:
result = result.replace("{project_memory_list}", "") 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() return result.strip()

View File

@ -37,6 +37,9 @@ TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN = 1 RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN = 1
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX = 30 RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX = 30
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT = 10 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_TRIGGER_TOKENS = 80_000
DEFAULT_SHALLOW_COMPRESS_KEEP_RECENT_TOOLS = 15 DEFAULT_SHALLOW_COMPRESS_KEEP_RECENT_TOOLS = 15
DEFAULT_SHALLOW_COMPRESS_MAX_REPLACE_PER_ROUND = 10 DEFAULT_SHALLOW_COMPRESS_MAX_REPLACE_PER_ROUND = 10
@ -73,6 +76,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"auto_generate_title": True, "auto_generate_title": True,
"recent_conversations_prompt_enabled": False, "recent_conversations_prompt_enabled": False,
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, "recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
"project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数None-无上限 / >=5-该值
"tool_intent_enabled": True, "tool_intent_enabled": True,
"skill_hints_enabled": False, # Skill 提示系统开关(默认关闭) "skill_hints_enabled": False, # Skill 提示系统开关(默认关闭)
"skill_strict_terminal_enabled": False, # 强约束terminal 系列工具需先阅读 terminal-guide "skill_strict_terminal_enabled": False, # 强约束terminal 系列工具需先阅读 terminal-guide
@ -132,6 +136,9 @@ __all__ = [
"RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN", "RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN",
"RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX", "RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX",
"RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT", "RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT",
"PROJECT_MEMORY_INJECT_LIMIT_MIN",
"PROJECT_MEMORY_INJECT_LIMIT_DEFAULT",
"resolve_project_memory_inject_limit",
"load_personalization_config", "load_personalization_config",
"save_personalization_config", "save_personalization_config",
"ensure_personalization_config", "ensure_personalization_config",
@ -659,6 +666,12 @@ def sanitize_personalization_payload(
else: else:
base["sub_agent_max_turns"] = _sanitize_sub_agent_max_turns(base.get("sub_agent_max_turns")) 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 return base
@ -675,6 +688,30 @@ def _sanitize_sub_agent_max_turns(value: Any) -> Optional[int]:
return min(parsed, 100_000) # 防御性上限;需要更大时用 0 表示无上限 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: def _sanitize_goal_end_conditions(value: Any) -> list:
"""清洗目标模式结束方式列表,保证至少包含 max_turns。""" """清洗目标模式结束方式列表,保证至少包含 max_turns。"""
cleaned: list = [] cleaned: list = []

View File

@ -6,10 +6,10 @@
- 记忆内容为提示而非事实,执行前应与实际代码验证 - 记忆内容为提示而非事实,执行前应与实际代码验证
### 总体长期记忆 ### 总体长期记忆
{global_memory} {global_memory}[global_memory_empty]暂无总体长期记忆[/global_memory_empty]
[global_memory_empty]暂无总体长期记忆[/global_memory_empty]
### 项目记忆(.astrion/memory/ ### 项目记忆(.astrion/memory/
{project_memory_list} [project_memory_truncated]共 {project_memory_total} 条记忆,按最近修改排序,仅注入前 {project_memory_limit} 条索引:
[project_memory_empty]暂无项目记忆[/project_memory_empty] [/project_memory_truncated]{project_memory_list}[project_memory_empty]暂无项目记忆[/project_memory_empty]
以上仅为索引(一句话描述)。处理项目相关任务前,用 search_project_memory 检索正文;定位后用 recall_project_memory 读全文。 [project_memory_truncated]其余 {project_memory_remaining} 条未注入(可用 search_project_memory 按关键词检索)。
[/project_memory_truncated]以上仅为索引(一句话描述)。处理项目相关任务前,用 search_project_memory 检索正文;定位后用 recall_project_memory 读全文。

View File

@ -22,6 +22,7 @@ from modules.personalization_manager import (
save_personalization_config, save_personalization_config,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
PROJECT_MEMORY_INJECT_LIMIT_MIN,
) )
from modules.skills_manager import ( from modules.skills_manager import (
get_skills_catalog, get_skills_catalog,
@ -286,6 +287,10 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
"recent_conversations_prompt_limit_range": { "recent_conversations_prompt_limit_range": {
"min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, "min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
"max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, "max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
},
"project_memory_inject_limit_range": {
"min": PROJECT_MEMORY_INJECT_LIMIT_MIN,
"max": None,
} }
}) })
except Exception as exc: except Exception as exc:
@ -372,6 +377,10 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
"recent_conversations_prompt_limit_range": { "recent_conversations_prompt_limit_range": {
"min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, "min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
"max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, "max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
},
"project_memory_inject_limit_range": {
"min": PROJECT_MEMORY_INJECT_LIMIT_MIN,
"max": None,
} }
}) })
except ValueError as exc: except ValueError as exc:

View File

@ -1087,6 +1087,21 @@
</button> </button>
</div> </div>
</div> </div>
<div class="settings-input-row">
<span class="settings-row-title">最大记忆注入</span>
<div class="settings-number-row">
<input
type="number"
:min="projectMemoryInjectLimitMin"
:value="form.project_memory_inject_limit ?? ''"
placeholder="无上限"
@input="handleProjectMemoryInjectLimitInput"
@blur="commitProjectMemoryInjectLimitInput"
/><button type="button" @click="restoreProjectMemoryInjectLimit">
恢复默认
</button>
</div>
</div>
<div class="settings-group-block"> <div class="settings-group-block">
<div class="settings-group-title"> <div class="settings-group-title">
<span class="settings-row-title">上下文压缩策略</span <span class="settings-row-title">上下文压缩策略</span
@ -1940,6 +1955,7 @@ const {
toolCategories, toolCategories,
skillsCatalog, skillsCatalog,
recentConversationsPromptLimitRange, recentConversationsPromptLimitRange,
projectMemoryInjectLimitMin,
experiments experiments
} = storeToRefs(personalization); } = storeToRefs(personalization);
@ -2893,6 +2909,32 @@ const restoreRecentConversationsPromptLimit = () => {
personalization.setRecentConversationsPromptLimit(null); 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 handleCompressionNumberInput = (key: CompressionField, event: Event) => {
const target = event.target as HTMLInputElement | null; const target = event.target as HTMLInputElement | null;
if (!target || !target.value) { if (!target || !target.value) {

View File

@ -17,6 +17,7 @@ interface PersonalForm {
auto_generate_title: boolean; auto_generate_title: boolean;
recent_conversations_prompt_enabled: boolean; recent_conversations_prompt_enabled: boolean;
recent_conversations_prompt_limit: number | string; recent_conversations_prompt_limit: number | string;
project_memory_inject_limit: number | string | null;
tool_intent_enabled: boolean; tool_intent_enabled: boolean;
skill_hints_enabled: boolean; skill_hints_enabled: boolean;
skill_strict_terminal_enabled: boolean; skill_strict_terminal_enabled: boolean;
@ -94,11 +95,14 @@ interface PersonalizationState {
toolCategories: Array<{ id: string; label: string }>; toolCategories: Array<{ id: string; label: string }>;
skillsCatalog: Array<{ id: string; label: string; description?: string }>; skillsCatalog: Array<{ id: string; label: string; description?: string }>;
recentConversationsPromptLimitRange: { min: number; max: number }; recentConversationsPromptLimitRange: { min: number; max: number };
projectMemoryInjectLimitMin: number;
experiments: ExperimentState; experiments: ExperimentState;
} }
const DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT = 10; const DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT = 10;
const DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE = { min: 1, max: 30 }; 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_SHALLOW_COMPRESS_TRIGGER_TOKENS = 80000;
const DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS = 150000; const DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS = 150000;
const RUN_MODE_OPTIONS: RunMode[] = ['fast', 'thinking']; const RUN_MODE_OPTIONS: RunMode[] = ['fast', 'thinking'];
@ -215,6 +219,7 @@ const defaultForm = (): PersonalForm => ({
auto_generate_title: true, auto_generate_title: true,
recent_conversations_prompt_enabled: false, recent_conversations_prompt_enabled: false,
recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT, recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT,
project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT,
tool_intent_enabled: true, tool_intent_enabled: true,
skill_hints_enabled: false, skill_hints_enabled: false,
skill_strict_terminal_enabled: false, skill_strict_terminal_enabled: false,
@ -338,6 +343,7 @@ export const usePersonalizationStore = defineStore('personalization', {
toolCategories: [], toolCategories: [],
skillsCatalog: [], skillsCatalog: [],
recentConversationsPromptLimitRange: { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE }, recentConversationsPromptLimitRange: { ...DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT_RANGE },
projectMemoryInjectLimitMin: PROJECT_MEMORY_INJECT_LIMIT_MIN,
experiments: loadExperimentState() experiments: loadExperimentState()
}), }),
actions: { actions: {
@ -419,6 +425,9 @@ export const usePersonalizationStore = defineStore('personalization', {
recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit( recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit(
data.recent_conversations_prompt_limit data.recent_conversations_prompt_limit
), ),
project_memory_inject_limit: this.normalizeProjectMemoryInjectLimit(
data.project_memory_inject_limit
),
tool_intent_enabled: !!data.tool_intent_enabled, tool_intent_enabled: !!data.tool_intent_enabled,
skill_hints_enabled: !!data.skill_hints_enabled, skill_hints_enabled: !!data.skill_hints_enabled,
skill_strict_terminal_enabled: !!data.skill_strict_terminal_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))); return Math.max(min, Math.min(max, Math.round(parsed)));
}, },
normalizeProjectMemoryInjectLimit(value: any): number | null {
// undefined老接口未下发/非法值 → 默认 20null/''/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) { applyPersonalizationMeta(payload: any) {
if (payload && payload.recent_conversations_prompt_limit_range) { if (payload && payload.recent_conversations_prompt_limit_range) {
const { min, max } = 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 ...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)) { if (payload && Array.isArray(payload.tool_categories)) {
this.toolCategories = payload.tool_categories this.toolCategories = payload.tool_categories
.map((item: { id?: string; label?: string } = {}) => ({ .map((item: { id?: string; label?: string } = {}) => ({
@ -786,6 +821,27 @@ export const usePersonalizationStore = defineStore('personalization', {
this.clearFeedback(); this.clearFeedback();
this.scheduleAutoSave(); 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) { toggleDefaultToolCategory(categoryId: string) {
if (!categoryId) { if (!categoryId) {
return; return;