#!/usr/bin/env python3
"""
s09_memory.py - 记忆系统
为编码 Agent 提供跨会话持久知识。
存储:
.memory/
MEMORY.md ← 索引(每条记忆一行,≤200 行)
feedback_tabs.md ← 独立记忆文件(Markdown + YAML frontmatter)
user_profile.md
project_facts.md
agent_loop 中的流程:
1. 将 MEMORY.md 索引加载到 SYSTEM 提示词(便宜,始终存在)
2. 按文件名/描述选择相关记忆 → 注入内容
3. 运行来自 s08 的压缩流水线
4. 每轮结束后 → 从原始消息中提取新记忆
5. 定期合并整理(Dream)
基于 s08(上下文压缩)构建。用法:
python s09_memory/code.py
需要: pip install anthropic python-dotenv + .env 中配置 ANTHROPIC_API_KEY
"""
import os, subprocess, json, time, re
from pathlib import Path
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"): os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
MEMORY_DIR = WORKDIR / ".memory"; MEMORY_DIR.mkdir(exist_ok=True)
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
SKILLS_DIR = WORKDIR / "skills"
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
TOOL_RESULTS_DIR = WORKDIR / ".task_outputs" / "tool-results"
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
# ═══════════════════════════════════════════════════════════
# 新增于 s09: 记忆系统
# ═══════════════════════════════════════════════════════════
MEMORY_TYPES = ["user", "feedback", "project", "reference"]
def _parse_frontmatter(text: str) -> tuple[dict, str]:
if not text.startswith("---"):
return {}, text
parts = text.split("---", 2)
if len(parts) < 3:
return {}, text
meta = {}
for line in parts[1].strip().splitlines():
if ":" in line:
k, v = line.split(":", 1)
meta[k.strip()] = v.strip().strip('"').strip("'")
return meta, parts[2].strip()
def write_memory_file(name: str, mem_type: str, description: str, body: str):
"""写入单个带 YAML frontmatter 的记忆文件。"""
slug = name.lower().replace(" ", "-").replace("/", "-")
filename = f"{slug}.md"
filepath = MEMORY_DIR / filename
filepath.write_text(
f"---\nname: {name}\ndescription: {description}\ntype: {mem_type}\n---\n\n{body}\n"
)
_rebuild_index()
return filepath
def _rebuild_index():
"""根据所有记忆文件重建 MEMORY.md 索引。"""
lines = []
for f in sorted(MEMORY_DIR.glob("*.md")):
if f.name == "MEMORY.md":
continue
raw = f.read_text()
meta, body = _parse_frontmatter(raw)
name = meta.get("name", f.stem)
desc = meta.get("description", body.split("\n")[0][:80])
lines.append(f"- [{name}]({f.name}) — {desc}")
MEMORY_INDEX.write_text("\n".join(lines) + "\n" if lines else "")
def read_memory_index() -> str:
"""读取每轮都会注入 SYSTEM 的 MEMORY.md 索引。"""
if not MEMORY_INDEX.exists():
return ""
text = MEMORY_INDEX.read_text().strip()
return text if text else ""
def read_memory_file(filename: str) -> str | None:
"""读取单个记忆文件的完整内容。"""
path = MEMORY_DIR / filename
if not path.exists():
return None
return path.read_text()
def list_memory_files() -> list[dict]:
"""列出所有记忆文件及其元数据。"""
result = []
for f in sorted(MEMORY_DIR.glob("*.md")):
if f.name == "MEMORY.md":
continue
raw = f.read_text()
meta, body = _parse_frontmatter(raw)
result.append({
"filename": f.name,
"name": meta.get("name", f.stem),
"description": meta.get("description", ""),
"type": meta.get("type", "user"),
"body": body,
})
return result
def select_relevant_memories(messages: list, max_items: int = 5) -> list[str]:
"""根据最近对话匹配记忆名称和描述,选择相关记忆文件。
优先使用一次简单的 LLM 调用;失败时回退到关键词匹配。"""
files = list_memory_files()
if not files:
return []
# 收集最近的用户文本作为上下文
recent_texts = []
for msg in reversed(messages):
if msg.get("role") == "user":
content = msg.get("content", "")
if isinstance(content, list):
content = " ".join(
str(getattr(b, "text", "")) for b in content
if getattr(b, "type", None) == "text"
)
if isinstance(content, str):
recent_texts.append(content)
if len(recent_texts) >= 3:
break
recent = " ".join(reversed(recent_texts))[:2000]
if not recent.strip():
return []
# 构建名称 + 描述目录,供 LLM 选择
catalog_lines = []
for i, f in enumerate(files):
catalog_lines.append(f"{i}: {f['name']} — {f['description']}")
catalog = "\n".join(catalog_lines)
prompt = (
"请根据最近对话和下方记忆目录,选择明显相关的记忆索引。"
"只返回 JSON 整数数组,例如 [0, 3]。"
"如果没有相关记忆,返回 []。\n\n"
f"最近对话:\n{recent}\n\n"
f"记忆目录:\n{catalog}"
)
try:
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=200,
)
text = extract_text(response.content).strip()
# 从响应中提取 JSON 数组
match = re.search(r'\[.*?\]', text, re.DOTALL)
if match:
indices = json.loads(match.group())
selected = []
for idx in indices:
if isinstance(idx, int) and 0 <= idx < len(files):
selected.append(files[idx]["filename"])
if len(selected) >= max_items:
break
return selected
except Exception:
pass
# 兜底:基于名称 + 描述做关键词匹配
keywords = [w.lower() for w in recent.split() if len(w) > 3]
selected = []
for f in files:
text = (f["name"] + " " + f["description"]).lower()
if any(kw in text for kw in keywords):
selected.append(f["filename"])
if len(selected) >= max_items:
break
return selected
def load_memories(messages: list) -> str:
"""加载相关记忆内容,用于注入上下文。"""
selected_files = select_relevant_memories(messages)
if not selected_files:
return ""
parts = [""]
for filename in selected_files:
content = read_memory_file(filename)
if content:
parts.append(content)
parts.append("")
return "\n\n".join(parts)
def extract_memories(messages: list):
"""从最近对话中提取新记忆。每轮结束后运行。"""
# 收集最近的对话文本
dialogue_parts = []
for msg in messages[-10:]:
role = msg.get("role", "?")
content = msg.get("content", "")
if isinstance(content, list):
content = " ".join(
str(getattr(b, "text", "")) for b in content
if getattr(b, "type", None) == "text"
)
if isinstance(content, str) and content.strip():
dialogue_parts.append(f"{role}: {content}")
dialogue = "\n".join(dialogue_parts)
if not dialogue.strip():
return
# 检查已有记忆以避免重复
existing = list_memory_files()
existing_desc = "\n".join(f"- {m['name']}: {m['description']}" for m in existing) if existing else "(无)"
prompt = (
"请从这段对话中提取用户偏好、约束或项目事实。\n"
"返回 JSON 数组。每一项格式为:{name, type, description, body}。\n"
"- name: 简短的 kebab-case 标识,例如 'user-preference-tabs'\n"
"- type: 只能是 'user'(用户偏好)、'feedback'(反馈/指导)、"
"'project'(项目事实)、'reference'(外部引用)之一\n"
"- description: 用于索引检索的一行摘要\n"
"- body: markdown 格式的完整细节\n"
"如果没有新内容,或已被现有记忆覆盖,返回 []。\n\n"
f"已有记忆:\n{existing_desc}\n\n"
f"对话:\n{dialogue[:4000]}"
)
try:
response = client.messages.create(
model=MODEL, messages=[{"role": "user", "content": prompt}], max_tokens=800
)
text = extract_text(response.content).strip()
# 从响应中提取 JSON 数组
match = re.search(r'\[.*\]', text, re.DOTALL)
if not match:
return
items = json.loads(match.group())
if not items:
return
count = 0
for mem in items:
name = mem.get("name", f"memory_{int(time.time())}")
mem_type = mem.get("type", "user")
desc = mem.get("description", "")
body = mem.get("body", "")
if desc and body:
write_memory_file(name, mem_type, desc, body)
count += 1
if count:
print(f"\n\033[33m[记忆:已提取 {count} 条新记忆]\033[0m")
except Exception:
pass
CONSOLIDATE_THRESHOLD = 10
def consolidate_memories():
"""合并重复或过时的记忆。文件数量达到阈值时触发。"""
files = list_memory_files()
if len(files) < CONSOLIDATE_THRESHOLD:
return
catalog = "\n\n".join(
f"## {f['filename']}\nname: {f['name']}\ndescription: {f['description']}\n{f['body']}"
for f in files
)
prompt = (
"请整理下列记忆文件。规则:\n"
"1. 合并重复记忆\n"
"2. 删除过时或互相矛盾的记忆\n"
"3. 总数控制在 30 条以内\n"
"4. 优先保留重要的用户偏好\n"
"返回 JSON 数组。每一项格式为:{name, type, description, body}。\n\n"
f"{catalog[:16000]}"
)
try:
response = client.messages.create(
model=MODEL, messages=[{"role": "user", "content": prompt}], max_tokens=3000
)
text = extract_text(response.content).strip()
match = re.search(r'\[.*\]', text, re.DOTALL)
if not match:
return
items = json.loads(match.group())
# 删除旧记忆文件(保留 MEMORY.md)
for f in MEMORY_DIR.glob("*.md"):
if f.name != "MEMORY.md":
f.unlink()
for mem in items:
name = mem.get("name", f"memory_{int(time.time())}")
mem_type = mem.get("type", "user")
desc = mem.get("description", "")
body = mem.get("body", "")
if desc and body:
write_memory_file(name, mem_type, desc, body)
print(f"\n\033[33m[记忆:已将 {len(files)} 条合并为 {len(items)} 条]\033[0m")
except Exception:
pass
# 使用记忆索引构建 SYSTEM
def build_system() -> str:
index = read_memory_index()
memories_section = f"\n\n可用记忆:\n{index}" if index else ""
return (
f"你是位于 {WORKDIR}."
f"{memories_section}\n"
"相关记忆会在下方注入。请遵循记忆中的用户偏好。\n"
"当用户说 'remember' 或表达明确偏好时,将其提取为记忆。"
)
SUB_SYSTEM = (
f"你是位于 {WORKDIR}. "
"完成交给你的任务,然后返回简洁摘要。"
"不要继续委派。"
)
# ═══════════════════════════════════════════════════════════
# 来自 s02-s08 (骨架): 基础工具
# ═══════════════════════════════════════════════════════════
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR): raise ValueError(f"路径逃逸出工作区:{p}")
return path
def run_bash(command: str) -> str:
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(无输出)"
except subprocess.TimeoutExpired: return "错误:执行超时(120 秒)"
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines): lines = lines[:limit] + [f"... ({len(lines) - limit} 行更多内容)"]
return "\n".join(lines)
except Exception as e: return f"错误:{e}"
def run_write(path: str, content: str) -> str:
try:
file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content); return f"已写入 {len(content)} 字节到 {path}"
except Exception as e: return f"错误:{e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text()
if old_text not in text: return f"错误:在文件中未找到目标文本:{path}"
file_path.write_text(text.replace(old_text, new_text, 1))
return f"已编辑 {path}"
except Exception as e: return f"错误:{e}"
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
results.append(match)
return "\n".join(results) if results else "(无匹配)"
except Exception as e: return f"错误:{e}"
def extract_text(content) -> str:
if not isinstance(content, list): return str(content)
return "\n".join(getattr(b, "text", "") for b in content if getattr(b, "type", None) == "text")
# 子 Agent(从 s06-s07 简化而来)
SUB_TOOLS = [
{"name": "bash", "description": "运行一条 shell 命令。",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "读取文件内容。",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "向文件写入内容。",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
]
SUB_HANDLERS = {"bash": run_bash, "read_file": run_read, "write_file": run_write}
def spawn_subagent(description: str) -> str:
print(f"\n\033[35m[子 Agent 已启动]\033[0m")
messages = [{"role": "user", "content": description}]
for _ in range(30):
response = client.messages.create(model=MODEL, system=SUB_SYSTEM,
messages=messages, tools=SUB_TOOLS, max_tokens=8000)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use": break
results = []
for block in response.content:
if block.type == "tool_use":
handler = SUB_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"未知工具:{block.name}"
print(f" \033[90m[sub] {block.name}: {str(output)[:100]}\033[0m")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
result = extract_text(messages[-1]["content"])
if not result:
for msg in reversed(messages):
if msg["role"] == "assistant":
result = extract_text(msg["content"])
if result: break
if not result: result = "子 Agent 已等待 30 轮仍未给出最终回答,已停止。"
print(f"\033[35m[子 Agent 已完成]\033[0m")
return result
# ═══════════════════════════════════════════════════════════
# 来自 s08(骨架): 压缩流水线
# ═══════════════════════════════════════════════════════════
CONTEXT_LIMIT = 50000; KEEP_RECENT = 3; PERSIST_THRESHOLD = 30000
def estimate_size(msgs): return len(str(msgs))
def _block_type(block):
return block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
def _message_has_tool_use(msg):
if msg.get("role") != "assistant":
return False
content = msg.get("content")
if not isinstance(content, list):
return False
return any(_block_type(block) == "tool_use" for block in content)
def _is_tool_result_message(msg):
if msg.get("role") != "user":
return False
content = msg.get("content")
if not isinstance(content, list):
return False
return any(isinstance(block, dict) and block.get("type") == "tool_result" for block in content)
def snip_compact(msgs, mx=50):
if len(msgs) <= mx: return msgs
head_end, tail_start = 3, len(msgs) - (mx - 3)
if head_end > 0 and _message_has_tool_use(msgs[head_end - 1]):
while head_end < len(msgs) and _is_tool_result_message(msgs[head_end]):
head_end += 1
if (tail_start > 0 and tail_start < len(msgs)
and _is_tool_result_message(msgs[tail_start])
and _message_has_tool_use(msgs[tail_start - 1])):
tail_start -= 1
if head_end >= tail_start:
return msgs
return msgs[:head_end] + [{"role": "user", "content": f"[已省略中间 {tail_start - head_end} 条消息]"}] + msgs[tail_start:]
def collect_tool_results(msgs):
blocks = []
for mi, msg in enumerate(msgs):
if msg.get("role") != "user" or not isinstance(msg.get("content"), list): continue
for bi, block in enumerate(msg["content"]):
if isinstance(block, dict) and block.get("type") == "tool_result": blocks.append((mi, bi, block))
return blocks
def micro_compact(msgs):
tr = collect_tool_results(msgs)
if len(tr) <= KEEP_RECENT: return msgs
for _, _, b in tr[:-KEEP_RECENT]:
if len(b.get("content", "")) > 120: b["content"] = "[早前工具结果已压缩。]"
return msgs
def persist_large(tid, out):
if len(out) <= PERSIST_THRESHOLD: return out
TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)
p = TOOL_RESULTS_DIR / f"{tid}.txt"
if not p.exists(): p.write_text(out)
return f"\n完整内容:{p}\n预览:\n{out[:2000]}\n"
def tool_result_budget(msgs, mx=200_000):
last = msgs[-1] if msgs else None
if not last or last.get("role") != "user" or not isinstance(last.get("content"), list): return msgs
blocks = [(i, b) for i, b in enumerate(last["content"]) if isinstance(b, dict) and b.get("type") == "tool_result"]
total = sum(len(str(b.get("content", ""))) for _, b in blocks)
if total <= mx: return msgs
for _, block in sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True):
if total <= mx: break
c = str(block.get("content", ""))
if len(c) <= PERSIST_THRESHOLD: continue
block["content"] = persist_large(block.get("tool_use_id", "?"), c)
total = sum(len(str(b.get("content", ""))) for _, b in blocks)
return msgs
def write_transcript(msgs):
TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
p = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with p.open("w") as f:
for m in msgs: f.write(json.dumps(m, default=str) + "\n")
return p
def summarize_history(msgs):
conv = json.dumps(msgs, default=str)[:80000]
r = client.messages.create(model=MODEL, messages=[{"role": "user", "content":
"总结这段编码 Agent 对话,以便继续工作。\n"
"保留:1. 当前目标,2. 关键发现,3. 已修改文件,4. 剩余工作,5. 用户约束。\n\n" + conv}],
max_tokens=2000)
return extract_text(r.content).strip()
def compact_history(msgs):
write_transcript(msgs)
summary = summarize_history(msgs)
return [{"role": "user", "content": f"[已压缩]\n\n{summary}"}]
def reactive_compact(msgs):
write_transcript(msgs)
tail_start = max(0, len(msgs) - 5)
if (tail_start > 0 and tail_start < len(msgs)
and _is_tool_result_message(msgs[tail_start])
and _message_has_tool_use(msgs[tail_start - 1])):
tail_start -= 1
summary = summarize_history(msgs[:tail_start])
return [{"role": "user", "content": f"[响应式压缩]\n\n{summary}"}, *msgs[tail_start:]]
# ═══════════════════════════════════════════════════════════
# 工具定义 (骨架 — 减少工具数量以聚焦记忆)
# ═══════════════════════════════════════════════════════════
TOOLS = [
{"name": "bash", "description": "运行一条 shell 命令。",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "读取文件内容。",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "向文件写入内容。",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "在文件中替换一次完全匹配的文本。",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "查找匹配 glob 模式的文件。",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
{"name": "task", "description": "启动一个子 Agent 来处理子任务。",
"input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}},
]
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"edit_file": run_edit, "glob": run_glob, "task": spawn_subagent,
}
# ═══════════════════════════════════════════════════════════
# agent_loop — s09: 注入记忆,并在每轮后提取
# ═══════════════════════════════════════════════════════════
MAX_REACTIVE_RETRIES = 1
def agent_loop(messages: list):
reactive_retries = 0
# s09: 把相关记忆内容注入当前用户轮次
memories_content = load_memories(messages)
memory_turn = len(messages) - 1 if messages and isinstance(messages[-1].get("content"), str) else None
# s09: 每个用户轮次构建一次系统提示词;循环返回后再更新记忆
system = build_system()
while True:
# s09: 保存压缩前快照,以便准确提取记忆
pre_compress = [m if isinstance(m, dict) else {"role": m.get("role",""),
"content": str(m.get("content",""))} for m in messages]
# s08: 压缩流水线 (budget → snip → micro)
messages[:] = tool_result_budget(messages)
messages[:] = snip_compact(messages)
messages[:] = micro_compact(messages)
if estimate_size(messages) > CONTEXT_LIMIT:
print("[自动压缩]")
messages[:] = compact_history(messages)
try:
request_messages = messages
if memories_content and memory_turn is not None and memory_turn < len(messages):
request_messages = messages.copy()
request_messages[memory_turn] = {
**messages[memory_turn],
"content": memories_content + "\n\n" + messages[memory_turn]["content"],
}
response = client.messages.create(
model=MODEL, system=system, messages=request_messages, tools=TOOLS, max_tokens=8000
)
reactive_retries = 0
except Exception as e:
if ("prompt_too_long" in str(e).lower() or "token 过多" in str(e).lower()) and reactive_retries < MAX_REACTIVE_RETRIES:
print("[响应式压缩]")
messages[:] = reactive_compact(messages)
reactive_retries += 1
continue
raise
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
# s09: 从压缩前快照中提取,保证完整性
extract_memories(pre_compress)
consolidate_memories()
return
results = []
for block in response.content:
if block.type != "tool_use": continue
print(f"\033[36m> {block.name}\033[0m")
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"未知工具:{block.name}"
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print("s09: 记忆 — 持久化跨会话知识")
print("输入问题,回车发送。输入 q 退出。\n")
history = []
while True:
try: query = input("\033[36ms09 >> \033[0m")
except (EOFError, KeyboardInterrupt): break
if query.strip().lower() in ("q", "exit", ""): break
history.append({"role": "user", "content": query})
agent_loop(history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text": print(block.text)
print()