feat(memory): 新增项目记忆最大注入条数设置,记忆索引按最近修改排序
- 个人空间-上下文新增「最大记忆注入」(默认 20,最小 5,留空无上限) - 提示词中项目记忆索引从文件名字典序改为按 mtime 倒序注入 - 超上限时截断并注入说明:共 N 条、仅注入前 x 条、其余用 search_project_memory 检索 - 修复 memory_system 模板条件块只删标记不删占位文字导致的「暂无xx记忆」残留
This commit is contained in:
parent
16900b0a20
commit
56c067b710
@ -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()
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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 读全文。
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user