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:
parent
a1e05032a2
commit
4a8c38d24a
@ -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时,应该索引本记忆'。不记录可从代码直接推断的或一次性信息。",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
"""将输入转换为整数并限制范围。"""
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -12,3 +12,4 @@
|
||||
### 项目记忆(.astrion/memory/)
|
||||
{project_memory_list}
|
||||
[project_memory_empty]暂无项目记忆[/project_memory_empty]
|
||||
以上仅为索引(一句话描述)。处理项目相关任务前,用 search_project_memory 检索正文;定位后用 recall_project_memory 读全文。
|
||||
|
||||
1
static/icons/notebook-search.svg
Normal file
1
static/icons/notebook-search.svg
Normal 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 |
@ -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
@ -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 || '';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '终端输入完成',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user