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 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()

View File

@ -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 = []

View File

@ -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 读全文。

View File

@ -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:

View File

@ -1087,6 +1087,21 @@
</button>
</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-title">
<span class="settings-row-title">上下文压缩策略</span
@ -1940,6 +1955,7 @@ const {
toolCategories,
skillsCatalog,
recentConversationsPromptLimitRange,
projectMemoryInjectLimitMin,
experiments
} = storeToRefs(personalization);
@ -2893,6 +2909,32 @@ const restoreRecentConversationsPromptLimit = () => {
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) {

View File

@ -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老接口未下发/非法值 → 默认 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) {
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;