diff --git a/README.md b/README.md
index 59609ecc..c48f6a72 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
[中文] | [English] | [日本語]
-**CowAgent** 是基于大模型的超级 AI 助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行 Skills、拥有长期记忆并不断成长,比 OpenClaw 更轻量和便捷。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
+**CowAgent** 是基于大模型的超级 AI 助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行 Skills、拥有长期记忆和知识库并不断成长,比 OpenClaw 更轻量和便捷。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
🌐 官网 ·
@@ -24,6 +24,7 @@
- ✅ **自主任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标
- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括核心记忆和日级记忆,支持关键词及向量检索
+- ✅ **个人知识库:** 自动整理结构化知识,通过交叉引用构建知识图谱,支持通过对话管理和可视化浏览知识库
- ✅ **技能系统:** Skills 安装和运行的引擎,支持从 [Skill Hub](https://skills.cowagent.ai/)、GitHub 等一键安装技能,或通过对话创造 Skills
- ✅ **工具系统:** 内置文件读写、终端执行、浏览器操作、定时任务等工具,Agent 自主调用以完成复杂任务
- ✅ **CLI系统:** 提供终端命令和对话命令,支持进程管理、技能安装、配置修改等操作
diff --git a/agent/chat/service.py b/agent/chat/service.py
index de7d345a..550063f1 100644
--- a/agent/chat/service.py
+++ b/agent/chat/service.py
@@ -57,7 +57,16 @@ class ChatService:
event_type = event.get("type")
data = event.get("data", {})
- if event_type == "message_update":
+ if event_type == "reasoning_update":
+ delta = data.get("delta", "")
+ if delta:
+ send_chunk_fn({
+ "chunk_type": "reasoning",
+ "delta": delta,
+ "segment_id": state.segment_id,
+ })
+
+ elif event_type == "message_update":
# Incremental text delta
delta = data.get("delta", "")
if delta:
diff --git a/agent/knowledge/__init__.py b/agent/knowledge/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/agent/knowledge/service.py b/agent/knowledge/service.py
new file mode 100644
index 00000000..a4fc3d6b
--- /dev/null
+++ b/agent/knowledge/service.py
@@ -0,0 +1,218 @@
+"""
+Knowledge service for handling knowledge base operations.
+
+Provides a unified interface for listing, reading, and graphing knowledge files,
+callable from the web console, API, or CLI.
+
+Knowledge file layout (under workspace_root):
+ knowledge/index.md
+ knowledge/log.md
+ knowledge//.md
+"""
+
+import os
+import re
+from pathlib import Path
+from typing import Optional
+
+from common.log import logger
+from config import conf
+
+
+class KnowledgeService:
+ """
+ High-level service for knowledge base queries.
+ Operates directly on the filesystem.
+ """
+
+ def __init__(self, workspace_root: str):
+ self.workspace_root = workspace_root
+ self.knowledge_dir = os.path.join(workspace_root, "knowledge")
+
+ # ------------------------------------------------------------------
+ # list — directory tree with stats
+ # ------------------------------------------------------------------
+ def list_tree(self) -> dict:
+ """
+ Return the knowledge directory tree grouped by category.
+
+ Returns::
+
+ {
+ "tree": [
+ {
+ "dir": "concepts",
+ "files": [
+ {"name": "moe.md", "title": "MoE", "size": 1234},
+ ...
+ ]
+ },
+ ...
+ ],
+ "stats": {"pages": 15, "size": 32768},
+ "enabled": true
+ }
+ """
+ if not os.path.isdir(self.knowledge_dir):
+ return {"tree": [], "stats": {"pages": 0, "size": 0}, "enabled": conf().get("knowledge", True)}
+
+ tree = []
+ total_files = 0
+ total_bytes = 0
+ for name in sorted(os.listdir(self.knowledge_dir)):
+ full = os.path.join(self.knowledge_dir, name)
+ if not os.path.isdir(full) or name.startswith("."):
+ continue
+ files = []
+ for fname in sorted(os.listdir(full)):
+ if fname.endswith(".md") and not fname.startswith("."):
+ fpath = os.path.join(full, fname)
+ size = os.path.getsize(fpath)
+ total_files += 1
+ total_bytes += size
+ title = fname.replace(".md", "")
+ try:
+ with open(fpath, "r", encoding="utf-8") as f:
+ first_line = f.readline().strip()
+ if first_line.startswith("# "):
+ title = first_line[2:].strip()
+ except Exception:
+ pass
+ files.append({"name": fname, "title": title, "size": size})
+ tree.append({"dir": name, "files": files})
+
+ return {
+ "tree": tree,
+ "stats": {"pages": total_files, "size": total_bytes},
+ "enabled": conf().get("knowledge", True),
+ }
+
+ # ------------------------------------------------------------------
+ # read — single file content
+ # ------------------------------------------------------------------
+ def read_file(self, rel_path: str) -> dict:
+ """
+ Read a single knowledge markdown file.
+
+ :param rel_path: Relative path within knowledge/, e.g. ``concepts/moe.md``
+ :return: dict with ``content`` and ``path``
+ :raises ValueError: if path is invalid or escapes knowledge dir
+ :raises FileNotFoundError: if file does not exist
+ """
+ if not rel_path or ".." in rel_path:
+ raise ValueError("invalid path")
+
+ full_path = os.path.normpath(os.path.join(self.knowledge_dir, rel_path))
+ allowed = os.path.normpath(self.knowledge_dir)
+ if not full_path.startswith(allowed + os.sep) and full_path != allowed:
+ raise ValueError("path outside knowledge dir")
+
+ if not os.path.isfile(full_path):
+ raise FileNotFoundError(f"file not found: {rel_path}")
+
+ with open(full_path, "r", encoding="utf-8") as f:
+ content = f.read()
+ return {"content": content, "path": rel_path}
+
+ # ------------------------------------------------------------------
+ # graph — nodes and links for visualization
+ # ------------------------------------------------------------------
+ def build_graph(self) -> dict:
+ """
+ Parse all knowledge pages and extract cross-reference links.
+
+ Returns::
+
+ {
+ "nodes": [
+ {"id": "concepts/moe.md", "label": "MoE", "category": "concepts"},
+ ...
+ ],
+ "links": [
+ {"source": "concepts/moe.md", "target": "entities/deepseek.md"},
+ ...
+ ]
+ }
+ """
+ knowledge_path = Path(self.knowledge_dir)
+ if not knowledge_path.is_dir():
+ return {"nodes": [], "links": []}
+
+ nodes = {}
+ links = []
+ link_re = re.compile(r'\[([^\]]*)\]\(([^)]+\.md)\)')
+
+ for md_file in knowledge_path.rglob("*.md"):
+ rel = str(md_file.relative_to(knowledge_path))
+ if rel in ("index.md", "log.md"):
+ continue
+ parts = rel.split("/")
+ category = parts[0] if len(parts) > 1 else "root"
+ title = md_file.stem.replace("-", " ").title()
+ try:
+ content = md_file.read_text(encoding="utf-8")
+ first_line = content.strip().split("\n")[0]
+ if first_line.startswith("# "):
+ title = first_line[2:].strip()
+ for _, link_target in link_re.findall(content):
+ resolved = (md_file.parent / link_target).resolve()
+ try:
+ target_rel = str(resolved.relative_to(knowledge_path))
+ except ValueError:
+ continue
+ if target_rel != rel:
+ links.append({"source": rel, "target": target_rel})
+ except Exception:
+ pass
+ nodes[rel] = {"id": rel, "label": title, "category": category}
+
+ valid_ids = set(nodes.keys())
+ links = [l for l in links if l["source"] in valid_ids and l["target"] in valid_ids]
+ seen = set()
+ deduped = []
+ for l in links:
+ key = tuple(sorted([l["source"], l["target"]]))
+ if key not in seen:
+ seen.add(key)
+ deduped.append(l)
+
+ return {"nodes": list(nodes.values()), "links": deduped}
+
+ # ------------------------------------------------------------------
+ # dispatch — single entry point for protocol messages
+ # ------------------------------------------------------------------
+ def dispatch(self, action: str, payload: Optional[dict] = None) -> dict:
+ """
+ Dispatch a knowledge management action.
+
+ :param action: ``list``, ``read``, or ``graph``
+ :param payload: action-specific payload
+ :return: protocol-compatible response dict
+ """
+ payload = payload or {}
+ try:
+ if action == "list":
+ result = self.list_tree()
+ return {"action": action, "code": 200, "message": "success", "payload": result}
+
+ elif action == "read":
+ path = payload.get("path")
+ if not path:
+ return {"action": action, "code": 400, "message": "path is required", "payload": None}
+ result = self.read_file(path)
+ return {"action": action, "code": 200, "message": "success", "payload": result}
+
+ elif action == "graph":
+ result = self.build_graph()
+ return {"action": action, "code": 200, "message": "success", "payload": result}
+
+ else:
+ return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
+
+ except ValueError as e:
+ return {"action": action, "code": 403, "message": str(e), "payload": None}
+ except FileNotFoundError as e:
+ return {"action": action, "code": 404, "message": str(e), "payload": None}
+ except Exception as e:
+ logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
+ return {"action": action, "code": 500, "message": str(e), "payload": None}
diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py
index a4f15aab..4ab0800b 100644
--- a/agent/memory/conversation_store.py
+++ b/agent/memory/conversation_store.py
@@ -188,8 +188,9 @@ def _group_into_display_turns(
if text:
turns.append({"role": "user", "content": text, "created_at": created_at})
- # Collect all tool_calls and tool_results from the rest of the group
- all_tool_calls: List[Dict[str, Any]] = []
+ # Build an ordered list of steps preserving the original sequence:
+ # thinking → content → tool_call → content → ...
+ steps: List[Dict[str, Any]] = []
tool_results: Dict[str, str] = {}
final_text = ""
final_ts: Optional[int] = None
@@ -198,24 +199,46 @@ def _group_into_display_turns(
if role == "user":
tool_results.update(_extract_tool_results(content))
elif role == "assistant":
- tcs = _extract_tool_calls(content)
- all_tool_calls.extend(tcs)
- t = _extract_display_text(content)
- if t:
- final_text = t
+ # Walk content blocks in order to preserve interleaving
+ if isinstance(content, list):
+ for block in content:
+ if not isinstance(block, dict):
+ continue
+ btype = block.get("type")
+ if btype == "thinking":
+ txt = block.get("thinking", "").strip()
+ if txt:
+ steps.append({"type": "thinking", "content": txt})
+ elif btype == "text":
+ txt = block.get("text", "").strip()
+ if txt:
+ steps.append({"type": "content", "content": txt})
+ final_text = txt
+ elif btype == "tool_use":
+ steps.append({
+ "type": "tool",
+ "id": block.get("id", ""),
+ "name": block.get("name", ""),
+ "arguments": block.get("input", {}),
+ })
+ elif isinstance(content, str) and content.strip():
+ steps.append({"type": "content", "content": content.strip()})
+ final_text = content.strip()
final_ts = created_at
- # Attach tool results to their matching tool_call entries
- for tc in all_tool_calls:
- tc["result"] = tool_results.get(tc.get("id", ""), "")
+ # Attach tool results to tool steps
+ for step in steps:
+ if step["type"] == "tool":
+ step["result"] = tool_results.get(step.get("id", ""), "")
- if final_text or all_tool_calls:
- turns.append({
+ if steps or final_text:
+ turn = {
"role": "assistant",
"content": final_text,
- "tool_calls": all_tool_calls,
+ "steps": steps,
"created_at": final_ts or (user_row[1] if user_row else 0),
- })
+ }
+ turns.append(turn)
return turns
@@ -312,6 +335,9 @@ class ConversationStore:
content = json.loads(raw_content)
except Exception:
content = raw_content
+ # Strip thinking blocks — they are stored for UI display only
+ if role == "assistant" and isinstance(content, list):
+ content = [b for b in content if b.get("type") != "thinking"]
result.append({"role": role, "content": content})
return result
diff --git a/agent/memory/manager.py b/agent/memory/manager.py
index 197c9ffd..9141bc91 100644
--- a/agent/memory/manager.py
+++ b/agent/memory/manager.py
@@ -285,6 +285,10 @@ class MemoryManager:
# Scan memory directory (including daily summaries)
if memory_dir.exists():
for file_path in memory_dir.rglob("*.md"):
+ # Skip hidden directories (e.g. .dreams/)
+ if any(part.startswith('.') for part in file_path.relative_to(workspace_dir).parts):
+ continue
+
# Determine scope and user_id from path
rel_path = file_path.relative_to(workspace_dir)
parts = rel_path.parts
@@ -312,6 +316,14 @@ class MemoryManager:
scope = "shared"
await self._sync_file(file_path, "memory", scope, user_id)
+
+ # Scan knowledge directory (structured knowledge wiki)
+ from config import conf
+ if conf().get("knowledge", True):
+ knowledge_dir = Path(workspace_dir) / "knowledge"
+ if knowledge_dir.exists():
+ for file_path in knowledge_dir.rglob("*.md"):
+ await self._sync_file(file_path, "knowledge", "shared", None)
self._dirty = False
diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py
index b280e1c1..c854ff1a 100644
--- a/agent/memory/summarizer.py
+++ b/agent/memory/summarizer.py
@@ -1,9 +1,10 @@
"""
-Memory flush manager
+Memory flush manager (with Light Dream)
Handles memory persistence when conversation context is trimmed or overflows:
- Uses LLM to summarize discarded messages into concise key-information entries
- Writes to daily memory files (lazy creation)
+- Light Dream: extracts long-term memories to MEMORY.md in the same LLM call
- Deduplicates trim flushes to avoid repeated writes
- Runs summarization asynchronously to avoid blocking normal replies
- Provides daily summary interface for scheduler
@@ -16,26 +17,41 @@ from datetime import datetime
from common.log import logger
-SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提炼出值得长期记住的关键事件和核心信息。
+SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提炼出两种记忆:
-核心原则:
-- 按「事件」维度归纳,而不是按对话轮次逐条记录
-- 多轮对话如果围绕同一件事,合并为一条摘要
-- 只记录有长期价值的信息,忽略闲聊、问候、无意义的短消息
+## 第一部分:日常记录([DAILY])
-输出要求:
-1. 每条一行,用 "- " 开头,格式为:事件/主题 + 关键结论或结果
-2. 值得记录的信息类型:用户提出的需求及最终解决方案、重要的事实信息、用户的偏好或决策、关键技术方案或配置变更
-3. 不值得记录的信息:简单问候、闲聊、无实质内容的短消息、重复的中间过程
-4. 每条摘要应当简明扼要,一句话概括事件的核心内容和结果
-5. 直接输出摘要内容,不要加任何前缀说明
-6. 当对话没有任何记录价值(仅含问候或无意义内容),回复"无"
+按「事件」维度归纳当天发生的事,不要按对话轮次逐条记录:
+- 每条一行,用 "- " 开头
+- 合并同一件事的多轮对话
+- 只记录有意义的事件,忽略闲聊和问候
-示例(仅供参考格式):
-- 用户配置了 XX 功能,设置参数为 YY,已生效
-- 用户反馈了 XX 问题,原因是 YY,通过 ZZ 方式解决"""
+## 第二部分:长期记忆([MEMORY])
-SUMMARIZE_USER_PROMPT = """请从以下对话记录中,按关键事件维度提炼记忆摘要(合并同一事件的多轮对话,不要逐条列出):
+提取值得**永久记住**的关键信息,这些信息在未来的对话中仍然有价值:
+- 用户的偏好、习惯、风格(如"用户偏好中文回复"、"用户喜欢简洁风格")
+- 重要的决策或约定(如"项目决定使用 PostgreSQL")
+- 关键人物信息(如"张总是用户的上级")
+- 用户明确要求记住的内容
+- 重要的教训或经验总结
+
+**如果没有值得永久记住的信息,[MEMORY] 部分留空即可。**
+
+## 输出格式(严格遵守)
+
+```
+[DAILY]
+- 事件1的摘要
+- 事件2的摘要
+
+[MEMORY]
+- 值得永久记住的信息1
+- 值得永久记住的信息2
+```
+
+当对话没有任何记录价值(仅含问候或无意义内容),直接回复"无"。"""
+
+SUMMARIZE_USER_PROMPT = """请从以下对话记录中提取记忆(按 [DAILY] 和 [MEMORY] 两部分输出):
{conversation}"""
@@ -160,40 +176,111 @@ class MemoryFlushManager:
reason: str,
max_messages: int,
):
- """Background worker: summarize with LLM and write to daily file."""
+ """Background worker: summarize with LLM, write daily file + MEMORY.md (Light Dream)."""
try:
- summary = self._summarize_messages(messages, max_messages)
- if not summary or not summary.strip() or summary.strip() == "无":
+ raw_summary = self._summarize_messages(messages, max_messages)
+ if not raw_summary or not raw_summary.strip() or raw_summary.strip() == "无":
logger.info(f"[MemoryFlush] No valuable content to flush (reason={reason})")
return
-
- daily_file = ensure_daily_memory_file(self.workspace_dir, user_id)
-
- if reason == "overflow":
- header = f"## Context Overflow Recovery ({datetime.now().strftime('%H:%M')})"
- note = "The following conversation was trimmed due to context overflow:\n"
- elif reason == "trim":
- header = f"## Trimmed Context ({datetime.now().strftime('%H:%M')})"
- note = ""
- elif reason == "daily_summary":
- header = f"## Daily Summary ({datetime.now().strftime('%H:%M')})"
- note = ""
- else:
- header = f"## Session Notes ({datetime.now().strftime('%H:%M')})"
- note = ""
-
- flush_entry = f"\n{header}\n\n{note}{summary}\n"
-
- with open(daily_file, "a", encoding="utf-8") as f:
- f.write(flush_entry)
-
+
+ daily_part, memory_part = self._parse_dual_output(raw_summary)
+
+ # --- Write daily memory ---
+ if daily_part:
+ daily_file = ensure_daily_memory_file(self.workspace_dir, user_id)
+
+ if reason == "overflow":
+ header = f"## Context Overflow Recovery ({datetime.now().strftime('%H:%M')})"
+ note = "The following conversation was trimmed due to context overflow:\n"
+ elif reason == "trim":
+ header = f"## Trimmed Context ({datetime.now().strftime('%H:%M')})"
+ note = ""
+ elif reason == "daily_summary":
+ header = f"## Daily Summary ({datetime.now().strftime('%H:%M')})"
+ note = ""
+ else:
+ header = f"## Session Notes ({datetime.now().strftime('%H:%M')})"
+ note = ""
+
+ flush_entry = f"\n{header}\n\n{note}{daily_part}\n"
+
+ with open(daily_file, "a", encoding="utf-8") as f:
+ f.write(flush_entry)
+
+ logger.info(f"[MemoryFlush] Wrote daily memory to {daily_file.name} (reason={reason}, chars={len(daily_part)})")
+
+ # --- Light Dream: write long-term memory to MEMORY.md ---
+ if memory_part:
+ self._append_to_main_memory(memory_part, user_id)
+
self.last_flush_timestamp = datetime.now()
-
- logger.info(f"[MemoryFlush] Wrote to {daily_file.name} (reason={reason}, chars={len(summary)})")
-
+
except Exception as e:
logger.warning(f"[MemoryFlush] Async flush failed (reason={reason}): {e}")
-
+
+ @staticmethod
+ def _parse_dual_output(raw: str) -> tuple:
+ """
+ Parse LLM output into (daily_part, memory_part).
+ Handles both new [DAILY]/[MEMORY] format and legacy single-section format.
+ """
+ raw = raw.strip()
+
+ if "[DAILY]" in raw or "[MEMORY]" in raw:
+ daily_part = ""
+ memory_part = ""
+
+ # Extract [DAILY] section
+ if "[DAILY]" in raw:
+ start = raw.index("[DAILY]") + len("[DAILY]")
+ end = raw.index("[MEMORY]") if "[MEMORY]" in raw else len(raw)
+ daily_part = raw[start:end].strip()
+
+ # Extract [MEMORY] section
+ if "[MEMORY]" in raw:
+ start = raw.index("[MEMORY]") + len("[MEMORY]")
+ memory_part = raw[start:].strip()
+
+ # Filter out empty markers
+ if memory_part and all(
+ not line.strip() or line.strip() == "-"
+ for line in memory_part.split("\n")
+ ):
+ memory_part = ""
+
+ return daily_part, memory_part
+
+ # Legacy format: treat entire output as daily, no memory extraction
+ return raw, ""
+
+ def _append_to_main_memory(self, memory_entries: str, user_id: Optional[str] = None):
+ """Append extracted long-term memories to MEMORY.md with date stamp."""
+ try:
+ main_file = self.get_main_memory_file(user_id)
+ today = datetime.now().strftime("%Y-%m-%d")
+
+ # Add date prefix to each entry line
+ stamped_lines = []
+ for line in memory_entries.strip().split("\n"):
+ line = line.strip()
+ if line.startswith("- "):
+ stamped_lines.append(f"- ({today}) {line[2:]}")
+ elif line:
+ stamped_lines.append(f"- ({today}) {line}")
+
+ if not stamped_lines:
+ return
+
+ stamped_text = "\n".join(stamped_lines)
+
+ with open(main_file, "a", encoding="utf-8") as f:
+ f.write(f"\n{stamped_text}\n")
+
+ logger.info(f"[LightDream] Appended {len(stamped_lines)} entries to MEMORY.md")
+
+ except Exception as e:
+ logger.warning(f"[LightDream] Failed to append to MEMORY.md: {e}")
+
def create_daily_summary(
self,
messages: List[Dict],
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index 1b96d2cf..6535abc9 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -10,6 +10,7 @@ from typing import List, Dict, Optional, Any
from dataclasses import dataclass
from common.log import logger
+from config import conf
@dataclass
@@ -92,10 +93,11 @@ def build_agent_system_prompt(
顺序说明(按重要性和逻辑关系排列):
1. 工具系统 - 核心能力,最先介绍
2. 技能系统 - 紧跟工具,因为技能需要用 read 工具读取
- 3. 记忆系统 - 独立的记忆能力
+ 3. 记忆系统 - 记忆检索与写入引导
+ 3.5 知识系统 - 结构化知识库(knowledge/index.md 注入)
4. 工作空间 - 工作环境说明
5. 用户身份 - 用户信息(可选)
- 6. 项目上下文 - AGENT.md, USER.md, RULE.md, BOOTSTRAP.md(定义人格、身份、规则、初始化引导)
+ 6. 项目上下文 - AGENT.md, USER.md, RULE.md, MEMORY.md, BOOTSTRAP.md
7. 运行时信息 - 元信息(时间、模型等)
Args:
@@ -126,6 +128,10 @@ def build_agent_system_prompt(
# 3. 记忆系统(独立的记忆能力)
if memory_manager:
sections.extend(_build_memory_section(memory_manager, tools, language))
+
+ # 3.5 知识系统(结构化知识库)
+ if conf().get("knowledge", True):
+ sections.extend(_build_knowledge_section(workspace_dir, language))
# 4. 工作空间(工作环境说明)
sections.extend(_build_workspace_section(workspace_dir, language))
@@ -268,55 +274,105 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu
"""构建记忆系统section"""
if not memory_manager:
return []
-
- # 检查是否有memory工具
+
has_memory_tools = False
if tools:
tool_names = [tool.name if hasattr(tool, 'name') else str(tool) for tool in tools]
has_memory_tools = any(name in ['memory_search', 'memory_get'] for name in tool_names)
-
+
if not has_memory_tools:
return []
-
+
from datetime import datetime
today_file = datetime.now().strftime("%Y-%m-%d") + ".md"
-
+
lines = [
"## 🧠 记忆系统",
"",
- "### 检索记忆",
+ "### Memory Recall(mandatory)",
"",
- "在回答关于以前的工作、决定、日期、人物、偏好或待办事项的任何问题之前:",
+ "在回答任何关于过往工作、决策、日期、人物、偏好或待办事项的问题之前,**必须**先检索记忆。",
+ "MEMORY.md 已自动加载在项目上下文中(可能被截断),完整内容和每日记忆需要通过工具检索。",
"",
- "1. 不确定记忆文件位置 → 先用 `memory_search` 通过关键词和语义检索相关内容",
- "2. 已知文件位置 → 直接用 `memory_get` 读取相应的行 (例如:MEMORY.md, memory/YYYY-MM-DD.md)",
- "3. search 无结果 → 尝试用 `memory_get` 读取MEMORY.md及最近两天记忆文件",
+ "1. 不确定位置 → `memory_search` 关键词/语义检索",
+ "2. 已知位置 → `memory_get` 直接读取对应行",
+ "3. search 无结果 → `memory_get` 读最近两天记忆",
"",
"**记忆文件结构**:",
- f"- `MEMORY.md`: 长期记忆(核心信息、偏好、决策等)",
+ "- `MEMORY.md`: 长期记忆索引(已自动加载到上下文,核心信息、偏好、决策等)",
f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`",
+ "- `knowledge/`: 结构化知识库(见下方知识系统)",
"",
"### 写入记忆",
"",
- "**主动存储**:遇到以下情况时,应主动将信息写入记忆文件(无需告知用户):",
+ "遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):",
"",
- "- 用户明确要求你记住某些信息",
+ "- 用户要求记住某些信息",
"- 用户分享了重要的个人偏好、习惯、决策",
"- 对话中产生了重要的结论、方案、约定",
"- 完成了复杂任务,值得记录关键步骤和结果",
- "- 发现了用户经常遇到的问题或解决方案",
"",
"**存储规则**:",
- f"- 长期有效的核心信息 → `MEMORY.md`(文件保持精简,< 2000 tokens)",
- f"- 当天的事件、进展、笔记 → `memory/{today_file}`",
- "- 追加内容 → `edit` 工具,oldText 留空",
- "- 修改内容 → `edit` 工具,oldText 填写要替换的文本",
- "- **禁止写入敏感信息**:API密钥、令牌等敏感信息严禁写入记忆文件",
+ f"- 长期核心信息 → `MEMORY.md`",
+ f"- 当天事件/进展 → `memory/{today_file}`",
+ "- 结构化知识 → `knowledge/`(见知识系统)",
+ "- 追加 → `edit` 工具,oldText 留空",
+ "- 修改 → `edit` 工具,oldText 填写要替换的文本",
+ "- **禁止写入敏感信息**(API密钥、令牌等)",
"",
"**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。",
"",
]
-
+
+ return lines
+
+
+def _build_knowledge_section(workspace_dir: str, language: str) -> List[str]:
+ """Build knowledge wiki section. Injects knowledge/index.md when present."""
+ index_path = os.path.join(workspace_dir, "knowledge", "index.md")
+ if not os.path.exists(index_path):
+ return []
+
+ try:
+ with open(index_path, 'r', encoding='utf-8') as f:
+ index_content = f.read().strip()
+ except Exception:
+ return []
+
+ lines = [
+ "## 📚 知识系统",
+ "",
+ "你拥有一个持续积累的个人知识库 `knowledge/`,这是你的长期结构化知识存储。",
+ "",
+ "### 自动写入规则(mandatory)",
+ "",
+ "以下场景**必须**在回复的同时写入知识库,**直接写入,不要询问用户是否需要**:",
+ "",
+ "1. **用户分享了文章/链接/文档** → 阅读理解后,在同一轮回复中将要点写入 `knowledge/sources/.md`",
+ "2. **深度讨论产生了结论/方案** → 整理为 `knowledge/analysis/.md`",
+ "3. **对话涉及重要实体**(人物/公司/项目)→ 创建或更新 `knowledge/entities/.md`",
+ "4. **讨论了技术概念/方法论** → 整理为 `knowledge/concepts/.md`",
+ "",
+ "每次写入知识页面后,**必须同步更新** `knowledge/index.md` 添加一行索引。",
+ "详细的页面格式和操作规范,请读取技能 `knowledge-wiki` 的 SKILL.md。",
+ "",
+ "⚠️ 不要问「要不要存到知识库」——符合上述场景就直接写入,这是你的本能行为。",
+ "",
+ ]
+
+ if index_content:
+ lines.extend([
+ "### 当前知识索引",
+ "",
+ index_content,
+ "",
+ ])
+
+ lines.extend([
+ "**查询方式**:用 `read` 读取知识页面,或用 `memory_search` 检索(知识已纳入向量索引)。",
+ "",
+ ])
+
return lines
@@ -375,11 +431,12 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"",
"**重要说明 - 文件已自动加载**:",
"",
- "以下文件在会话启动时**已经自动加载**到系统提示词的「项目上下文」section 中,你**无需再用 read 工具读取它们**:",
+ "以下文件在会话启动时**已经自动加载**到系统提示词中,你**无需再用 read 工具读取**:",
"",
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
+ "- ✅ `MEMORY.md`: 已加载 - 长期记忆索引",
"",
"**💬 交流规范**:",
"",
diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py
index 68eec912..797006ce 100644
--- a/agent/prompt/workspace.py
+++ b/agent/prompt/workspace.py
@@ -67,6 +67,12 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
# 创建websites子目录 (for web pages / sites generated by agent)
websites_dir = os.path.join(workspace_dir, "websites")
os.makedirs(websites_dir, exist_ok=True)
+
+ from config import conf
+ knowledge_enabled = conf().get("knowledge", True)
+ if knowledge_enabled:
+ knowledge_dir = os.path.join(workspace_dir, "knowledge")
+ os.makedirs(knowledge_dir, exist_ok=True)
# 如果需要,创建模板文件
if create_templates:
@@ -74,6 +80,15 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
_create_template_if_missing(user_path, _get_user_template())
_create_template_if_missing(rule_path, _get_rule_template())
_create_template_if_missing(memory_path, _get_memory_template())
+ if knowledge_enabled:
+ _create_template_if_missing(
+ os.path.join(knowledge_dir, "index.md"),
+ _get_knowledge_index_template()
+ )
+ _create_template_if_missing(
+ os.path.join(knowledge_dir, "log.md"),
+ _get_knowledge_log_template()
+ )
# Only create BOOTSTRAP.md for brand new workspaces;
# agent deletes it after completing onboarding
@@ -109,6 +124,7 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] =
DEFAULT_AGENT_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_RULE_FILENAME,
+ DEFAULT_MEMORY_FILENAME, # Long-term memory (frozen snapshot)
DEFAULT_BOOTSTRAP_FILENAME, # Only exists when onboarding is incomplete
]
@@ -138,6 +154,10 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] =
# 跳过空文件或只包含模板占位符的文件
if not content or _is_template_placeholder(content):
continue
+
+ # Truncate MEMORY.md to protect context window (frozen snapshot)
+ if filename == DEFAULT_MEMORY_FILENAME:
+ content = _truncate_memory_content(content)
context_files.append(ContextFile(
path=filename,
@@ -163,6 +183,36 @@ def _create_template_if_missing(filepath: str, template_content: str):
logger.error(f"[Workspace] Failed to create template {filepath}: {e}")
+_MEMORY_MAX_LINES = 200
+_MEMORY_MAX_BYTES = 25000
+
+
+def _truncate_memory_content(content: str) -> str:
+ """Truncate MEMORY.md to keep system prompt manageable.
+
+ Takes the **last** N lines (newest entries are appended at the bottom),
+ subject to 200 lines / 25 KB limits (whichever is hit first).
+ Prepends a hint when truncated so the model knows older content exists.
+ """
+ lines = content.split('\n')
+ truncated = False
+
+ if len(lines) > _MEMORY_MAX_LINES:
+ lines = lines[-_MEMORY_MAX_LINES:]
+ truncated = True
+
+ result = '\n'.join(lines)
+ if len(result.encode('utf-8')) > _MEMORY_MAX_BYTES:
+ while len(result.encode('utf-8')) > _MEMORY_MAX_BYTES and lines:
+ lines.pop(0)
+ truncated = True
+ result = '\n'.join(lines)
+
+ if truncated:
+ result = "...(older entries truncated, use `memory_search` or `memory_get` for full content)\n\n" + result
+ return result
+
+
def _is_template_placeholder(content: str) -> bool:
"""检查内容是否为模板占位符"""
# 常见的占位符模式
@@ -287,39 +337,88 @@ def _get_rule_template() -> str:
这个文件夹是你的家。好好对待它。
+## 工作空间目录结构
+
+```
+~/cow/
+├── AGENT.md # 你的身份和灵魂设定
+├── USER.md # 用户基本信息(静态)
+├── RULE.md # 工作空间规则(本文件)
+├── MEMORY.md # 长期记忆索引(会话启动时自动加载)
+│
+├── memory/ # 每日对话记忆
+│ └── YYYY-MM-DD.md # 当天事件、进展、笔记
+│
+├── knowledge/ # 结构化知识库(持续积累的知识)
+│ ├── index.md # 知识目录索引(必须维护)
+│ ├── log.md # 知识操作日志
+│ └── <子目录>/ # 按需创建,参考 index.md 已有分类
+│
+├── skills/ # 技能
+├── websites/ # 网页产物
+└── tmp/ # 系统临时文件(自动管理,勿手动存放重要文件)
+```
+
## 记忆系统
你每次会话都是全新的,记忆文件让你保持连续性:
-### 📝 每日记忆:`memory/YYYY-MM-DD.md`
-- 原始的对话日志
-- 记录当天发生的事情
-- 如果 `memory/` 目录不存在,创建它
-
### 🧠 长期记忆:`MEMORY.md`
-- 你精选的记忆,就像人类的长期记忆
-- **仅在主会话中加载**(与用户的直接聊天)
-- **不要在共享上下文中加载**(群聊、与其他人的会话)
-- 这是为了**安全** - 包含不应泄露给陌生人的个人上下文
-- 记录重要事件、想法、决定、观点、经验教训
-- 这是你精选的记忆 - 精华,而不是原始日志
-- 用 `edit` 工具追加新的记忆内容
+- 你精选的记忆索引,每次会话启动时**自动加载**到上下文中
+- 记录核心事实、偏好、决策、重要人物、教训
+- 保持精简(< 200 行),是精华索引而非原始日志
+- 用 `edit` 工具追加或修改
+
+### 📝 每日记忆:`memory/YYYY-MM-DD.md`
+- 当天的事件、进展、笔记
+- 原始对话日志的沉淀
### 📝 写下来 - 不要"记在心里"!
-- **记忆是有限的** - 如果你想记住某事,写入文件
+- **记忆是有限的** - 想记住的事就写入文件
- "记在心里"不会在会话重启后保留,文件才会
- 当有人说"记住这个" → 更新 `MEMORY.md` 或 `memory/YYYY-MM-DD.md`
- 当你学到教训 → 更新 RULE.md 或相关技能
-- 当你犯错 → 记录下来,这样未来的你不会重复,**文字 > 大脑** 📝
+- 当你犯错 → 记录下来,**文字 > 大脑** 📝
### 存储规则
当用户分享信息时,根据类型选择存储位置:
-1. **你的身份设定 → AGENT.md**(你的名字、角色、性格、交流风格——用户修改时必须用 `edit` 更新)
-2. **用户静态身份 → USER.md**(姓名、称呼、职业、时区、联系方式、生日——用户修改时必须用 `edit` 更新)
-3. **动态记忆 → MEMORY.md**(爱好、偏好、决策、目标、项目、教训、待办事项)
+1. **你的身份设定 → AGENT.md**(名字、角色、性格、风格)
+2. **用户静态身份 → USER.md**(姓名、称呼、职业、联系方式、生日)
+3. **动态记忆 → MEMORY.md**(偏好、决策、目标、教训、待办)
4. **当天对话 → memory/YYYY-MM-DD.md**(今天聊的内容)
+5. **结构化知识 → knowledge/**(见下方知识系统)
+
+## 知识系统
+
+知识库 `knowledge/` 是你持续积累的结构化知识。与记忆不同,知识是经过整理和编译的,有明确的主题和交叉引用。
+
+### 自动写入(不要询问,直接写入)
+
+当对话中产生了有沉淀价值的知识——无论是用户分享的资料、讨论的结论、学到的概念、还是重要的决策——你**必须**在回复的同时主动写入知识库,**无需问用户"要不要存到知识库"**。
+
+**关键原则**:学完就记是你的本能,不要征求确认。回复中可以顺带告知"已存入知识库"。
+
+### 目录组织
+
+子目录结构**不是固定的**,由你根据实际内容自主决定:
+- **首次写入时**:先读 `knowledge/index.md`,如果已有分类则延续;如果为空,根据内容选择合适的目录名
+- **默认建议**:按信息类型组织(例如sources/、concepts/、entities/、analysis/),如果用户有明确的分类偏好(例如按领域 work/、life/、tech/ 等),则按用户要求调整
+- **保持一致性**:同一用户的知识库应保持统一的组织风格
+
+### 交叉引用
+
+知识的核心价值在于**关联**。每个页面都应通过 markdown 链接引用相关页面,构建知识网络:
+- 提到已有页面的概念时,添加 `[概念名](../category/page.md)` 链接
+- 新建页面时,检查是否有已有页面应该反向链接到新页面
+- **只链接已存在的页面**——不要引用尚未创建的页面。如果某个概念值得单独建页,先创建该页面再添加链接
+
+### 索引维护
+
+每次创建或更新知识页面后,**必须同步更新** `knowledge/index.md`。
+索引格式:每行一个 `[标题](路径) — 一句话摘要`,按分类分组,不要用表格。
+详细操作规范见技能 `knowledge-wiki`。
## 安全
@@ -381,4 +480,12 @@ _你刚刚启动,这是你的第一次对话。_ ✨
"""
+def _get_knowledge_index_template() -> str:
+ """Knowledge wiki index template — empty file, agent fills it."""
+ return ""
+
+
+def _get_knowledge_log_template() -> str:
+ """Knowledge wiki operation log template — empty file, agent fills it."""
+ return ""
diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py
index 1b250011..45f7d8a5 100644
--- a/agent/protocol/agent_stream.py
+++ b/agent/protocol/agent_stream.py
@@ -527,6 +527,7 @@ class AgentStreamExecutor:
# Streaming response
full_content = ""
+ full_reasoning = ""
tool_calls_buffer = {} # {index: {id, name, arguments}}
gemini_raw_parts = None # Preserve Gemini thoughtSignature for round-trip
stop_reason = None # Track why the stream stopped
@@ -584,10 +585,10 @@ class AgentStreamExecutor:
if finish_reason:
stop_reason = finish_reason
- # Skip reasoning_content (internal thinking from models like GLM-5)
reasoning_delta = delta.get("reasoning_content") or ""
- # if reasoning_delta:
- # logger.debug(f"🧠 [thinking] {reasoning_delta[:100]}...")
+ if reasoning_delta:
+ full_reasoning += reasoning_delta
+ self._emit_event("reasoning_update", {"delta": reasoning_delta})
# Handle text content
content_delta = delta.get("content") or ""
@@ -788,7 +789,12 @@ class AgentStreamExecutor:
# Add assistant message to history (Claude format uses content blocks)
assistant_msg = {"role": "assistant", "content": []}
- # Add text content block if present
+ if full_reasoning:
+ assistant_msg["content"].append({
+ "type": "thinking",
+ "thinking": full_reasoning
+ })
+
if full_content:
assistant_msg["content"].append({
"type": "text",
diff --git a/agent/skills/manager.py b/agent/skills/manager.py
index c7daf7ad..ddb2a316 100644
--- a/agent/skills/manager.py
+++ b/agent/skills/manager.py
@@ -210,6 +210,10 @@ class SkillManager:
if not include_disabled:
entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
+ from config import conf
+ if not conf().get("knowledge", True):
+ entries = [e for e in entries if e.skill.name != "knowledge-wiki"]
+
return entries
def filter_unavailable_skills(
diff --git a/agent/tools/memory/memory_get.py b/agent/tools/memory/memory_get.py
index 64e4d4de..ec466849 100644
--- a/agent/tools/memory/memory_get.py
+++ b/agent/tools/memory/memory_get.py
@@ -44,6 +44,19 @@ class MemoryGetTool(BaseTool):
"""
super().__init__()
self.memory_manager = memory_manager
+
+ from config import conf
+ if conf().get("knowledge", True):
+ self.description = (
+ "Read specific content from memory or knowledge files. "
+ "Use this to get full context from a memory file, knowledge page, or specific line range."
+ )
+ self.params = {**self.params}
+ self.params["properties"] = {**self.params["properties"]}
+ self.params["properties"]["path"] = {
+ "type": "string",
+ "description": "Relative path to the memory or knowledge file (e.g. 'MEMORY.md', 'memory/2026-01-01.md', 'knowledge/concepts/moe.md')"
+ }
def execute(self, args: dict):
"""
@@ -68,11 +81,15 @@ class MemoryGetTool(BaseTool):
workspace_dir = self.memory_manager.config.get_workspace()
# Auto-prepend memory/ if not present and not absolute path
- # Exception: MEMORY.md is in the root directory
- if not path.startswith('memory/') and not path.startswith('/') and path != 'MEMORY.md':
+ # Exceptions: MEMORY.md in root, knowledge/ files at workspace root
+ if not path.startswith('memory/') and not path.startswith('knowledge/') and not path.startswith('/') and path != 'MEMORY.md':
path = f'memory/{path}'
- file_path = workspace_dir / path
+ file_path = (workspace_dir / path).resolve()
+ workspace_resolved = workspace_dir.resolve()
+
+ if not str(file_path).startswith(str(workspace_resolved) + '/') and file_path != workspace_resolved:
+ return ToolResult.fail(f"Error: Access denied: path outside workspace")
if not file_path.exists():
return ToolResult.fail(f"Error: File not found: {path}")
diff --git a/agent/tools/memory/memory_search.py b/agent/tools/memory/memory_search.py
index d7b14df3..1387d11c 100644
--- a/agent/tools/memory/memory_search.py
+++ b/agent/tools/memory/memory_search.py
@@ -48,6 +48,13 @@ class MemorySearchTool(BaseTool):
super().__init__()
self.memory_manager = memory_manager
self.user_id = user_id
+
+ from config import conf
+ if conf().get("knowledge", True):
+ self.description = (
+ "Search agent's long-term memory and knowledge base using semantic and keyword search. "
+ "Use this to recall past conversations, preferences, and knowledge pages."
+ )
def execute(self, args: dict):
"""
diff --git a/bridge/agent_event_handler.py b/bridge/agent_event_handler.py
index b04c77b8..50826235 100644
--- a/bridge/agent_event_handler.py
+++ b/bridge/agent_event_handler.py
@@ -26,8 +26,7 @@ class AgentEventHandler:
if context:
self.channel = context.kwargs.get("channel") if hasattr(context, "kwargs") else None
- # Track current thinking for channel output
- self.current_thinking = ""
+ self.current_content = ""
self.turn_number = 0
def handle_event(self, event):
@@ -47,6 +46,8 @@ class AgentEventHandler:
self._handle_message_update(data)
elif event_type == "message_end":
self._handle_message_end(data)
+ elif event_type == "reasoning_update":
+ pass
elif event_type == "tool_execution_start":
self._handle_tool_execution_start(data)
elif event_type == "tool_execution_end":
@@ -59,30 +60,26 @@ class AgentEventHandler:
def _handle_turn_start(self, data):
"""Handle turn start event"""
self.turn_number = data.get("turn", 0)
- self.has_tool_calls_in_turn = False
- self.current_thinking = ""
+ self.current_content = ""
def _handle_message_update(self, data):
- """Handle message update event (streaming text)"""
+ """Handle message update event (streaming content text)"""
delta = data.get("delta", "")
- self.current_thinking += delta
+ self.current_content += delta
def _handle_message_end(self, data):
"""Handle message end event"""
tool_calls = data.get("tool_calls", [])
- # Only send thinking process if followed by tool calls
if tool_calls:
- if self.current_thinking.strip():
- logger.info(f"💭 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}")
- # Send thinking process to channel
- self._send_to_channel(f"{self.current_thinking.strip()}")
+ if self.current_content.strip():
+ logger.info(f"💭 {self.current_content.strip()[:200]}{'...' if len(self.current_content) > 200 else ''}")
+ self._send_to_channel(self.current_content.strip())
else:
- # No tool calls = final response (logged at agent_stream level)
- if self.current_thinking.strip():
- logger.debug(f"💬 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}")
+ if self.current_content.strip():
+ logger.debug(f"💬 {self.current_content.strip()[:200]}{'...' if len(self.current_content) > 200 else ''}")
- self.current_thinking = ""
+ self.current_content = ""
def _handle_tool_execution_start(self, data):
"""Handle tool execution start event - logged by agent_stream.py"""
diff --git a/channel/web/chat.html b/channel/web/chat.html
index 6d3ba6e3..a291858c 100644
--- a/channel/web/chat.html
+++ b/channel/web/chat.html
@@ -110,6 +110,11 @@
Memory
+