feat(memory): 新增项目记忆检索工具 search_project_memory

- 后端:关键词全文检索 .astrion/memory/*.md(名称+10/描述+5/正文加权),
  返回 top-N 与带行号片段;注册为主 agent 只读工具,子智能体工具集同步;
  memory_system 模板与多智能体 prompt 增加检索指引(第二触点)
- 工具 description 含硬性触发条件、query 撰写指引、负例与防重试契约
- 前端:新增 notebook-search 组合图标(notebook 轮廓开口 + 右下角放大镜);
  工具详情渲染命中记忆的名称/描述/行号片段;活动汇总归入 memory_read
- 重构:ToolAction.vue 删除约 1600 行重复渲染函数,统一委托
  toolRenderers.ts(与 StackedBlocks/MinimalBlocks 同一实现)
This commit is contained in:
JOJO 2026-07-29 02:00:01 +08:00
parent a1e05032a2
commit 4a8c38d24a
15 changed files with 281 additions and 1548 deletions

View File

@ -109,7 +109,7 @@ class ToolsDefinitionContextToolsMixin:
"type": "function",
"function": {
"name": "recall_project_memory",
"description": "读取指定的项目记忆文件,返回完整内容(含 frontmatter。项目记忆存储在 .astrion/memory/ 目录下。",
"description": "读取指定的项目记忆文件,返回完整内容(含 frontmatter。项目记忆存储在 .astrion/memory/ 目录下。通常在 search_project_memory 检索定位后使用;若不确定记忆名称,先检索再读取。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
@ -125,6 +125,31 @@ class ToolsDefinitionContextToolsMixin:
{
"type": "function",
"function": {
"name": "search_project_memory",
"description": "在项目记忆文件(.astrion/memory/*.md的正文和描述中全文检索返回匹配的记忆文件与片段。【硬性要求】开始处理与本项目相关的任务前修改代码、调试报错、配置部署或任务可能涉及项目约定/历史决策/已知问题时必须先调用本工具检索根据任务内容、用户要求和项目记忆索引中的描述提取2~5个清晰精确的关键词优先使用项目术语、模块名、文件名、报错文本中英文均可。纯闲聊、与项目无关的通用问题不要调用。未命中时会明确返回无结果此时不要更换同义词重复尝试命中后如需完整内容用 recall_project_memory 读取。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"keywords": {
"type": "array",
"description": "搜索关键词列表建议2~5个任一关键词命中即算匹配命中数越多排名越靠前",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 5
},
"max_results": {
"type": "integer",
"description": "最多返回多少条匹配记忆默认5最大10"
}
}),
"required": ["keywords"]
}
}
},
{
"type": "function",
"function": {
"name": "update_project_memory",
"description": "创建或覆盖项目记忆文件。当你发现有关当前项目的重要约定/决策/坑、用户表现出对项目的偏好或用户主动要求「在当前项目里下次要xxx/记住xxx」时应积极主动地调用本工具。记忆名称用英文下划线描述格式为'当xxxx时应该索引本记忆'。不记录可从代码直接推断的或一次性信息。",

View File

@ -192,6 +192,7 @@ class MainTerminalToolsExecutionMixin:
"ocr_image",
"update_memory",
"recall_project_memory",
"search_project_memory",
"update_project_memory",
"conversation_search",
"conversation_review",
@ -1746,6 +1747,25 @@ class MainTerminalToolsExecutionMixin:
else:
result = self._handle_recall_project_memory(name)
elif tool_name == "search_project_memory":
raw_keywords = arguments.get("keywords")
if isinstance(raw_keywords, list):
keywords = [
str(item).strip()
for item in raw_keywords
if str(item or "").strip()
][:5]
else:
keywords = []
if not keywords:
result = {"success": False, "error": "search_project_memory 需要 keywords 参数(至少 1 个关键词)"}
else:
try:
max_results = int(arguments.get("max_results") or 5)
except Exception:
max_results = 5
result = self._handle_search_project_memory(keywords, max_results)
elif tool_name == "update_project_memory":
name = str(arguments.get("name", "")).strip()
description = str(arguments.get("description", "")).strip()

View File

@ -174,6 +174,139 @@ class MainTerminalToolsReadMixin:
result["memory_name"] = safe_name
return result
def _handle_search_project_memory(self, keywords: List[str], max_results: int = 5) -> Dict:
"""处理 search_project_memory在 .astrion/memory/*.md 中做关键词全文检索。
评分名称命中 +10描述命中 +5正文每行命中 +1单关键词最多计 5
返回 top-N 结果附匹配行片段行号基于完整文件可直接配合 read_file extract 使用
"""
clean_keywords: List[str] = []
for kw in keywords or []:
kw_text = str(kw or "").strip()
if kw_text and kw_text not in clean_keywords:
clean_keywords.append(kw_text)
clean_keywords = clean_keywords[:5]
if not clean_keywords:
return {"success": False, "error": "search_project_memory 需要至少 1 个关键词"}
max_results = self._clamp_int(max_results, 5, 1, 10)
memory_dir = Path(self.project_path) / WORKSPACE_MEMORY_DIRNAME
if not memory_dir.exists() or not memory_dir.is_dir():
empty_text = "项目记忆目录不存在,暂无项目记忆可检索。"
return {
"success": True,
"count": 0,
"keywords": clean_keywords,
"results": [],
"content": empty_text,
"summary": empty_text,
}
lowered = [(kw, kw.lower()) for kw in clean_keywords]
scored: List[Dict[str, Any]] = []
for md_file in sorted(memory_dir.glob("*.md")):
try:
text = md_file.read_text(encoding="utf-8")
except Exception:
continue
lines = text.split("\n")
name = md_file.stem
description = ""
body_start_idx = 0 # 0-based正文起始行跳过 frontmatter
if lines and lines[0].strip() == "---":
for i in range(1, len(lines)):
if lines[i].strip() == "---":
for fm_line in lines[1:i]:
fm_stripped = fm_line.strip()
if fm_stripped.startswith("name:"):
name = fm_stripped.split(":", 1)[1].strip() or name
elif fm_stripped.startswith("description:"):
description = fm_stripped.split(":", 1)[1].strip()
body_start_idx = i + 1
break
name_lower = name.lower()
desc_lower = description.lower()
body_lines = lines[body_start_idx:]
score = 0
matched_keywords: List[str] = []
for kw, kw_lower in lowered:
kw_score = 0
if kw_lower in name_lower:
kw_score += 10
if kw_lower in desc_lower:
kw_score += 5
body_hits = sum(1 for line in body_lines if kw_lower in line.lower())
kw_score += min(body_hits, 5)
if kw_score > 0:
score += kw_score
matched_keywords.append(kw)
if score <= 0:
continue
snippets: List[Dict[str, Any]] = []
for idx in range(body_start_idx, len(lines)):
line_stripped = lines[idx].strip()
if not line_stripped:
continue
line_lower = line_stripped.lower()
if any(kw_lower in line_lower for _, kw_lower in lowered):
snippet_text = line_stripped if len(line_stripped) <= 120 else line_stripped[:117] + "..."
snippets.append({"line": idx + 1, "text": snippet_text})
if len(snippets) >= 3:
break
scored.append({
"file": md_file.name,
"name": name,
"description": description,
"score": score,
"matched_keywords": matched_keywords,
"snippets": snippets,
})
scored.sort(key=lambda item: (-item["score"], -len(item["matched_keywords"]), item["name"]))
top = scored[:max_results]
if not top:
empty_text = (
f"未找到匹配的项目记忆(关键词:{''.join(clean_keywords)})。"
"不要更换关键词重复检索;继续当前任务即可。"
)
return {
"success": True,
"count": 0,
"keywords": clean_keywords,
"results": [],
"content": empty_text,
"summary": "未找到匹配的项目记忆",
}
content_lines = [f"找到 {len(top)} 个匹配的项目记忆(关键词:{''.join(clean_keywords)}", ""]
for rank, item in enumerate(top, start=1):
content_lines.append(f"[{rank}] {item['name']}.astrion/memory/{item['file']}")
if item["description"]:
content_lines.append(f" 描述:{item['description']}")
if item["snippets"]:
content_lines.append(" 匹配片段:")
for snippet in item["snippets"]:
content_lines.append(f" L{snippet['line']}: {snippet['text']}")
content_lines.append("")
content_lines.append("如需完整内容,使用 recall_project_memory 读取对应记忆。")
content_text = "\n".join(content_lines).strip()
return {
"success": True,
"count": len(top),
"keywords": clean_keywords,
"results": top,
"content": content_text,
"summary": f"找到 {len(top)} 个匹配的项目记忆",
}
@staticmethod
def _clamp_int(value, default, min_value=None, max_value=None):
"""将输入转换为整数并限制范围。"""

View File

@ -207,7 +207,7 @@ def _build_project_memory_section(workspace_path: str) -> str:
f"## 项目记忆\n\n"
f"项目记忆以文件形式存放在 `.astrion/memory/` 下。\n"
f"开始涉及项目约定、历史决策或已知问题的任务前,先检查项目记忆索引,"
f"如有相关记忆则用 recall_project_memory 读取。\n\n"
f"不确定时先用 search_project_memory 检索正文,定位后用 recall_project_memory 读取。\n\n"
f"### 项目记忆索引\n\n"
+ "\n".join(entries)
+ "\n"

View File

@ -177,7 +177,7 @@ SUB_AGENT_TOOLS: List[Dict[str, Any]] = [
"type": "function",
"function": {
"name": "recall_project_memory",
"description": "读取指定的项目记忆文件,返回完整内容(含 frontmatter。项目记忆存储在 .astrion/memory/ 目录下。",
"description": "读取指定的项目记忆文件,返回完整内容(含 frontmatter。项目记忆存储在 .astrion/memory/ 目录下。通常在 search_project_memory 检索定位后使用;若不确定记忆名称,先检索再读取。",
"parameters": {
"type": "object",
"properties": {
@ -187,6 +187,27 @@ SUB_AGENT_TOOLS: List[Dict[str, Any]] = [
},
},
},
{
"type": "function",
"function": {
"name": "search_project_memory",
"description": "在项目记忆文件(.astrion/memory/*.md的正文和描述中全文检索返回匹配的记忆文件与片段。【硬性要求】开始处理与本项目相关的任务前修改代码、调试报错、配置部署或任务可能涉及项目约定/历史决策/已知问题时必须先调用本工具检索根据任务内容和项目记忆索引中的描述提取2~5个清晰精确的关键词优先使用项目术语、模块名、文件名、报错文本中英文均可。与本项目无关的通用问题不要调用。未命中时会明确返回无结果此时不要更换同义词重复尝试命中后如需完整内容用 recall_project_memory 读取。",
"parameters": {
"type": "object",
"properties": {
"keywords": {
"type": "array",
"description": "搜索关键词列表建议2~5个任一关键词命中即算匹配命中数越多排名越靠前",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 5,
},
"max_results": {"type": "integer", "description": "最多返回多少条匹配记忆默认5最大10"},
},
"required": ["keywords"],
},
},
},
{
"type": "function",
"function": {
@ -327,6 +348,11 @@ def _format_tool_result(name: str, raw: Any) -> str:
if raw.get("content"):
return raw.get("content")
return raw.get("message") or raw.get("error") or "记忆已读取"
if name == "search_project_memory":
# search_project_memory 返回预格式化的检索结果文本
if raw.get("content"):
return raw.get("content")
return raw.get("message") or raw.get("error") or "检索完成"
if name == "todo_create":
return raw.get("message") or "待办列表已创建"
if name == "todo_update_task":

View File

@ -12,3 +12,4 @@
### 项目记忆(.astrion/memory/
{project_memory_list}
[project_memory_empty]暂无项目记忆[/project_memory_empty]
以上仅为索引(一句话描述)。处理项目相关任务前,用 search_project_memory 检索正文;定位后用 recall_project_memory 读全文。

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M2 6h4m-4 4h4m-4 4h4m-4 4h4"/><path d="M18 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h6"/><path d="M20 11V4a2 2 0 0 0-2-2"/><circle cx="16.5" cy="16.5" r="3.5"/><path d="m21 21-2-2"/></svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@ -639,6 +639,7 @@ const TOOL_CATEGORY_MAP: Record<string, ToolCategory> = {
//
recall_project_memory: 'memory_read',
search_project_memory: 'memory_read',
//
conversation_search: 'conversation',

File diff suppressed because it is too large Load Diff

View File

@ -123,6 +123,8 @@ export function renderEnhancedToolResult(
return renderUpdateMemory(result, args);
} else if (name === 'recall_project_memory') {
return renderRecallProjectMemory(result, args);
} else if (name === 'search_project_memory') {
return renderSearchProjectMemory(result, args);
} else if (name === 'update_project_memory') {
return renderUpdateProjectMemory(result, args);
} else if (name === 'conversation_search') {
@ -993,6 +995,53 @@ function renderRecallProjectMemory(result: any, args: any): string {
return html;
}
function renderSearchProjectMemory(result: any, args: any): string {
const keywords = Array.isArray(result?.keywords)
? result.keywords
: Array.isArray(args?.keywords)
? args.keywords
: [];
const results = Array.isArray(result?.results) ? result.results : [];
const status = formatToolStatusLabel(result, '✓ 完成');
let html = '<div class="tool-result-meta">';
html += `<div><strong>关键词:</strong>${escapeHtml(keywords.join(' / ') || '(未指定)')}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>命中记忆:</strong>${results.length} 条</div>`;
if (!result?.success && result?.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
html += '</div>';
if (!results.length) {
if (result?.success) {
html += '<div class="tool-result-empty">未找到匹配的项目记忆。</div>';
}
return html;
}
html += '<div class="search-result-list">';
results.forEach((item: any) => {
html += '<div class="search-result-item">';
html += `<div class="search-result-title">${escapeHtml(item.name || item.file || '')}</div>`;
if (item.description) {
html += `<div><strong>描述:</strong>${escapeHtml(item.description)}</div>`;
}
const snippets = Array.isArray(item.snippets) ? item.snippets : [];
if (snippets.length > 0) {
const snippetText = snippets
.map((snippet: any) => `L${snippet.line ?? '?'}: ${snippet.text || ''}`)
.join('\n');
html += '<div class="search-match-item">';
html += `<pre>${escapeHtml(snippetText)}</pre>`;
html += '</div>';
}
html += '</div>';
});
html += '</div>';
return html;
}
function renderUpdateProjectMemory(result: any, args: any): string {
const name = args.name || result.memory_name || '';
const description = args.description || '';

View File

@ -114,6 +114,7 @@ const TOOL_SCENE_MAP: Record<string, string> = {
sleep: 'terminalSleep',
update_memory: 'memoryUpdate',
recall_project_memory: 'reader',
search_project_memory: 'reader',
update_project_memory: 'memoryUpdate',
todo_create: 'todoCreate',
todo_update_task: 'todoUpdate',

View File

@ -23,6 +23,7 @@ const TOOL_FACE_MAP: Record<string, string> = {
run_command: 'command',
update_memory: 'brain',
recall_project_memory: 'notebook',
search_project_memory: 'search',
update_project_memory: 'notebook',
todo_create: 'note',
todo_update_task: 'check',

View File

@ -19,6 +19,7 @@ const RUNNING_ANIMATIONS: Record<string, string> = {
run_command: 'terminal-animation',
update_memory: 'memory-animation',
recall_project_memory: 'read-animation',
search_project_memory: 'search-animation',
update_project_memory: 'memory-animation',
sleep: 'wait-animation',
terminal_session: 'terminal-animation',
@ -44,6 +45,7 @@ const RUNNING_STATUS_TEXTS: Record<string, string> = {
run_command: '调用 run_command',
update_memory: '正在更新记忆...',
recall_project_memory: '正在回顾项目记忆...',
search_project_memory: '正在检索项目记忆...',
update_project_memory: '正在更新项目记忆...',
terminal_session: '正在管理终端会话...',
terminal_input: '调用 terminal_input',
@ -68,6 +70,7 @@ const COMPLETED_STATUS_TEXTS: Record<string, string> = {
run_command: '命令执行完成',
update_memory: '记忆更新成功',
recall_project_memory: '项目记忆已读取',
search_project_memory: '项目记忆检索完成',
update_project_memory: '项目记忆已更新',
terminal_session: '终端操作完成',
terminal_input: '终端输入完成',

View File

@ -32,6 +32,7 @@ export const ICONS = Object.freeze({
navigation: '/static/icons/navigation.svg',
notebook: '/static/icons/notebook.svg',
notebookPen: '/static/icons/notebook-pen.svg',
notebookSearch: '/static/icons/notebook-search.svg',
octagon: '/static/icons/octagon.svg',
pencil: '/static/icons/pencil.svg',
python: '/static/icons/python.svg',
@ -81,6 +82,7 @@ export const TOOL_ICON_MAP = Object.freeze({
unfocus_file: 'eye',
update_memory: 'brain',
recall_project_memory: 'notebook',
search_project_memory: 'notebookSearch',
update_project_memory: 'notebookPen',
conversation_search: 'search',
conversation_review: 'book',

View File

@ -80,6 +80,12 @@ def format_tool_result_for_context(function_name: str, result_data: Any, raw_tex
if function_name in {"read_file", "read_skill", "recall_project_memory"} and isinstance(result_data, dict):
return format_read_file_result(result_data)
if function_name == "search_project_memory" and isinstance(result_data, dict):
content_text = str(result_data.get("content") or "").strip()
if content_text:
return content_text
return str(result_data.get("summary") or result_data.get("error") or "")
if function_name == "write_file_diff" and isinstance(result_data, dict):
return _format_write_file_diff(result_data, raw_text)