refactor(evolve): consolidate auto memory planner and writer into single step (#267)
Some checks failed
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Tests ReMe / Unit Tests - py3.10 (push) Has been cancelled
Tests ReMe / Unit Tests - py3.13 (push) Has been cancelled

* refactor(evolve): consolidate auto memory planner and writer into single step

- Removed separate AutoMemoryPlannerStep and AutoMemoryWriterStep classes
- Combined functionality into new AutoMemoryStep class in auto_memory.py
- Migrated prompt templates from separate YAML files to unified auto_memory.yaml
- Updated module imports to reference new consolidated step
- Simplified memory recording process using single ReAct agent instead of two-stage planning/writing
- Maintained same input/output contract with messages, session_id, and memory_hint parameters
- Preserved all original functionality for creating/updating daily notes with conversation facts

* fix(daily): update empty session_id handling to create day-level file

- Changed test to verify empty session_id creates day-level file daily/<date>.md
- Updated assertion to check response success instead of rejection
- Modified metadata verification to include path, session_id and created status
- Added file existence check for the generated daily markdown file
- Updated test name and print statement to reflect new behavior
- Fixed test registration to use updated function name
This commit is contained in:
jinliyl 2026-05-29 18:02:10 +08:00 • committed by GitHub
parent 9ee2f0f7ab
commit c4ca617992
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 483 additions and 675 deletions

View file

@ -101,12 +101,12 @@ litellm = [
]
light = [
"agentscope==1.0.19",
"agentscope==1.0.20",
"flowllm[reme]>=0.2.0.10",
]
core = [
"agentscope==1.0.19",
"agentscope==1.0.20",
]
[tool.setuptools.packages.find]

View file

@ -109,19 +109,18 @@ jobs:
daily_create:
backend: base
description: "Provision a session note under a daily folder: daily/<date>/<session_id>.md"
description: "Provision a session note under a daily folder: daily/<date>/<session_id>.md or daily/<date>.md"
parameters:
type: object
properties:
session_id:
type: string
description: "the session identifier (also the file stem)"
description: "the session identifier (also the file stem); empty = day-level file"
default: ""
date:
type: string
description: "YYYY-MM-DD; empty = today"
default: ""
required:
- session_id
steps:
- backend: daily_create_step
@ -371,7 +370,7 @@ jobs:
auto_memory:
backend: base
description: "Auto-memory orchestrator"
description: "Auto-memory: record conversation facts into a daily note"
parameters:
type: object
properties:
@ -380,6 +379,10 @@ jobs:
description: "messages"
items:
type: object
session_id:
type: string
description: "session identifier passed to daily_create"
default: ""
memory_hint:
type: string
description: "optional hint"
@ -390,8 +393,7 @@ jobs:
required:
- messages
steps:
- backend: auto_memory_planner_step
- backend: auto_memory_writer_step
- backend: auto_memory_step
components:
tokenizer:

View file

@ -7,8 +7,7 @@ from .common.help import HelpStep
from .common.llm_demo import LLMDemoStep
from .common.stream_demo import StreamDemoStep1, StreamDemoStep2
from .common.version import VersionStep
from .evolve.auto_memory_planner import AutoMemoryPlannerStep
from .evolve.auto_memory_writer import AutoMemoryWriterStep
from .evolve.auto_memory import AutoMemoryStep
from .file_io.daily_create import DailyCreateStep
from .file_io.daily_list import DailyListStep
from .file_io.daily_reindex import DailyReindexStep
@ -46,8 +45,7 @@ __all__ = [
"StreamDemoStep2",
"VersionStep",
# evolve
"AutoMemoryPlannerStep",
"AutoMemoryWriterStep",
"AutoMemoryStep",
# file_io
"DeleteStep",
"EditStep",

View file

@ -0,0 +1,88 @@
"""``auto_memory`` — record conversation facts into a daily note.
Calls ``daily_create`` as a system call to provision the note path,
then hands off to a ReAct agent that reads existing content (if any),
decides what to preserve, and writes the note via ``read`` / ``edit``
/ ``frontmatter_update`` / ``write`` tools.
Inputs (from RuntimeContext):
messages (list[Msg], required): conversation slice to inspect.
session_id (str, optional): passed to daily_create to determine
the note path.
memory_hint (str, optional): caller-supplied hint for the agent.
timezone (str, optional): IANA timezone for date resolution.
Output (written to context.response):
answer: one-line summary from the agent.
metadata: {path, created}.
"""
from agentscope.agent import ReActAgent
from agentscope.message import Msg
from agentscope.tool import Toolkit
from ._evolve import format_history, now
from ..base_step import BaseStep
from ...components import R
@R.register("auto_memory_step")
class AutoMemoryStep(BaseStep):
"""Record conversation facts into a daily note via a ReAct agent."""
def __init__(self, console_enabled: bool = False, **kwargs):
super().__init__(**kwargs)
self.console_enabled = console_enabled
self.agent_tools: list[str] = ["read", "edit", "frontmatter_update", "write"]
async def execute(self):
assert self.context is not None
messages: list[Msg] = [
item if isinstance(item, Msg) else Msg.from_dict(item) for item in self.context.get("messages", [])
]
session_id: str = self.context.get("session_id", "")
memory_hint: str = self.context.get("memory_hint", "")
current = now(self.context.get("timezone"))
if not messages:
self.context.response.success = True
self.context.response.answer = "Skipped: no messages supplied"
return
create_response = await self.run_job("daily_create", session_id=session_id)
if not create_response.success:
self.context.response.success = False
self.context.response.answer = f"daily_create failed: {create_response.answer}"
return
note_path: str = create_response.metadata["path"]
created: bool = create_response.metadata["created"]
toolkit = Toolkit()
for job_name in self.agent_tools:
self.add_as_tool(toolkit, job_name)
agent = ReActAgent(
name="auto_memory",
model=self.as_llm,
sys_prompt=self.prompt_format("system_prompt"),
formatter=self.as_llm_formatter,
toolkit=toolkit,
)
agent.set_console_output_enabled(self.console_enabled)
template_key = "user_message_create" if created else "user_message_update"
user_message: str = self.prompt_format(
template_key,
today=current.strftime("%Y-%m-%d"),
vault_dir=str(self.file_store.vault_path),
note=memory_hint or "(none)",
note_path=note_path,
history=format_history(messages),
)
final_msg: Msg = await agent.reply(Msg(name="reme", role="user", content=user_message))
self.context.response.success = True
self.context.response.answer = (final_msg.get_text_content() or "").strip()
self.context.response.metadata.update({"path": note_path, "created": created})

View file

@ -0,0 +1,230 @@
system_prompt: |
You are an automatic memory system. Your job is to record key information from recent conversations into a daily note at the specified path. Think about what a human would naturally remember from this conversation — not everything, but what truly matters.
## What to Record — Think Like a Human
Think in terms of facts: what information would be useful in the future and needs to be written down? Capture things that are hard to re-obtain:
- Persistent facts about the user — who they are, how they work, what they want
- What happened, what decisions were made, and why — narrative threads
- Current state — progress, blockers, next steps — stale by tomorrow but critical today
- Actionable procedures or solutions that can be directly reused
- Anything you consider important that doesn't fit the above — if you think it'll be useful later, write it down
Be comprehensive — every fact worth keeping should appear. Quote original wording or numbers verbatim at key points.
## Body Format
Free-form — use whatever structure best fits the content (headings, lists, etc.). The only hard rule is **completeness**.
## Frontmatter Rules
- `name` = the filename stem, copied verbatim. Do not Title-Case or rewrite it.
- `description` = a thorough summary; vague descriptions like "notes" / "misc" are unacceptable.
- **Never set `status`** — it is a field reserved for downstream processing.
system_prompt_zh: |
你是自动记忆系统。你的职责是将最近对话中的核心信息记录到指定路径的日记中。思考人类会从这段对话中自然地记住什么——不是所有内容,而是真正重要的信息。
## 记录什么——像人类一样思考
从事实的角度想:哪些信息未来会有用,需要记下来?捕捉那些难以重新获取的信息:
- 关于用户的持久事实——他们是谁、怎么工作、想要什么
- 发生了什么事、做了什么决策、为什么——叙事线索
- 当前状态——进度、卡点、下一步——明天就会过时但今天很重要
- 可以直接复用的操作步骤或方案
- 你认为重要但不属于以上类别的信息——如果你觉得以后会用到,就记下来
要全面——每一条值得保留的事实都应出现。关键处逐字引用原始措辞或数字。
## 正文格式
自由格式——用最适合内容的结构(标题、列表等)。唯一的硬性规则是**完整性**。
## Frontmatter 规则
- `name` = 文件名 stem,逐字照抄。不要 Title-Case 化,不要改写。
- `description` = 详细总结;模糊的描述如 "notes" / "misc" 不可接受。
- **永远不要设置 `status`**——它是下游处理保留的字段。
user_message_create: |
Today: {today}
Vault directory: {vault_dir}
Extra hint: {note}
Target path: {note_path}
# Recent Conversation
{history}
# Your Task
Record the key information from the conversation above into the daily note at the target path.
## Step 1 — Skip Check
Did the conversation produce substantive information worth long-term memory? Pure greetings or small talk → reply with a brief skip message and stop (do not call any tools).
When truly ambiguous, default to writing — losing a memory is worse than writing one extra note.
## Step 2 — Write
The target file is a newly created empty file. Write the full content in one shot:
`write path={note_path} name=<name> description=<description> content=<body>`
- `name` must equal the filename stem of the target path (the part between the last `/` and `.md`), copied verbatim.
- `description` must be a thorough summary of the body — specific enough that the description alone conveys all key information.
## Step 3 — Summary
State in one sentence what you did (which file was created). This is your final text output.
## Boundaries
- Only operate on one target path: `{note_path}`. Do not touch other notes.
user_message_create_zh: |
今天:{today}
Vault 目录:{vault_dir}
额外提示:{note}
目标路径:{note_path}
# 最近的对话
{history}
# 你的任务
将上述对话中的核心信息记录到目标路径的日记中。
## 步骤 1 — 跳过检查
对话是否产生了值得长期记忆的实质性信息?纯粹的寒暄或闲聊 → 回复一条简短的跳过消息并停止(不调用任何工具)。
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
## 步骤 2 — 写入
目标文件是新建的空文件。一次性写入完整内容:
`write path={note_path} name=<name> description=<description> content=<正文>`
- `name` 必须等于目标路径的文件名 stem(最后一个 `/` 与 `.md` 之间的部分),逐字照抄。
- `description` 必须是正文的详尽总结——具体到仅凭 description 就能传达全部核心信息。
## 步骤 3 — 总结
用一句话说明你做了什么(创建了哪个文件)。这是你最后一次文本输出。
## 边界
- 只针对一个目标路径:`{note_path}`。不要碰其他笔记。
user_message_update: |
Today: {today}
Vault directory: {vault_dir}
Extra hint: {note}
Target path: {note_path}
# Recent Conversation
{history}
# Your Task
Merge key information from the conversation above into the existing daily note at the target path.
## Step 1 — Skip Check
Did the conversation produce substantive information worth long-term memory? Pure greetings or small talk → reply with a brief skip message and stop (do not call any tools).
When truly ambiguous, default to writing — losing a memory is worse than writing one extra note.
## Step 2 — Read Existing Content
Call `read path={note_path}` to inspect the current note content.
- If the body is empty (only frontmatter, no actual content) → treat as new, jump to **Step 3b**.
- If there is body content → go to **Step 3a** to merge.
## Step 3a — Merge Update
The note already has content. Your task is to merge new information into it.
Merge rules:
- **Timeline / history entries**: append only, never delete existing entries.
- **Current-state entries** (progress, blockers, next steps, open questions): rewrite the entire section to reflect the latest snapshot.
- **Everything else**: merge and deduplicate — keep all old facts, add new facts, remove exact duplicates.
Execution:
1. Use `edit path={note_path} old=<original fragment> new=<replacement fragment>` for each section that needs updating. You may call `edit` multiple times.
2. After body changes, refresh the frontmatter description: `frontmatter_update path={note_path} metadata={{"description": "<updated summary>"}}`.
3. If `edit` fails repeatedly (e.g., cannot find the original text due to formatting mismatch), fall back to `write path={note_path} name=<name> description=<description> content=<full body>` for a complete rewrite.
## Step 3b — Full Write (Empty File Fallback)
The file exists but its body is empty. Write the full content in one shot:
`write path={note_path} name=<name> description=<description> content=<body>`
- `name` must equal the filename stem of the target path (the part between the last `/` and `.md`), copied verbatim.
- `description` must be a thorough summary of the body — specific enough that the description alone conveys all key information.
## Step 4 — Summary
State in one sentence what you did (what content was updated). This is your final text output.
## Boundaries
- Only operate on one target path: `{note_path}`. Do not touch other notes.
- `write` unconditionally overwrites body and frontmatter — use with caution.
user_message_update_zh: |
今天:{today}
Vault 目录:{vault_dir}
额外提示:{note}
目标路径:{note_path}
# 最近的对话
{history}
# 你的任务
将上述对话中的核心信息合并到目标路径的已有日记中。
## 步骤 1 — 跳过检查
对话是否产生了值得长期记忆的实质性信息?纯粹的寒暄或闲聊 → 回复一条简短的跳过消息并停止(不调用任何工具)。
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
## 步骤 2 — 读取现有内容
调用 `read path={note_path}` 查看当前笔记内容。
- 如果正文为空(只有 frontmatter 无实际内容)→ 按新建处理,跳到 **步骤 3b**。
- 如果有正文内容 → 转到 **步骤 3a** 进行合并。
## 步骤 3a — 合并更新
笔记已有内容。你的任务是将新信息合并进去。
合并规则:
- **时间线 / 历史条目**:仅追加,永远不删除已有条目。
- **当下状态类条目**(进度、卡点、下一步、未决问题):整段重写,反映最新快照。
- **其余内容**:合并去重——保留全部旧事实,添加新事实,去除完全重复项。
执行:
1. 对需要更新的每个部分使用 `edit path={note_path} old=<原文片段> new=<替换片段>`。可以多次调用 `edit`。
2. 正文变更后,刷新 frontmatter 的 description:`frontmatter_update path={note_path} metadata={{"description": "<更新后的总结>"}}`。
3. 如果 `edit` 多次失败(如因格式不匹配找不到原文),退回 `write path={note_path} name=<name> description=<description> content=<完整正文>` 全量重写。
## 步骤 3b — 全量写入(空文件 fallback)
文件存在但正文为空。一次性写入完整内容:
`write path={note_path} name=<name> description=<description> content=<正文>`
- `name` 必须等于目标路径的文件名 stem(最后一个 `/` 与 `.md` 之间的部分),逐字照抄。
- `description` 必须是正文的详尽总结——具体到仅凭 description 就能传达全部核心信息。
## 步骤 4 — 总结
用一句话说明你做了什么(更新了哪些内容)。这是你最后一次文本输出。
## 边界
- 只针对一个目标路径:`{note_path}`。不要碰其他笔记。
- `write` 会无条件覆盖正文和 frontmatter,请谨慎使用。

View file

@ -1,119 +0,0 @@
"""``auto_memory_planner`` — planner for the auto-memory system.
Inspects the recent conversation, surveys today's existing daily notes
via ``daily_list``, reads any candidate that already covers the topic
via ``read``, and emits a list of daily-note upsert tasks
``(path, description)`` through structured output. The result is
exposed under ``response.metadata['memory_updates']`` for the
orchestrator (``auto_memory``) to feed into ``auto_memory_writer``
one task at a time.
The planner never writes notes itself — planning only.
Inputs (from RuntimeContext):
messages (list[Msg], required): conversation slice to inspect.
memory_hint (str, optional): caller-supplied hint to bias filename
stem selection or disambiguate same-day tasks.
Output (written to context.response):
answer: one-line human summary of what was planned.
metadata['memory_updates']: list of ``{path, description}`` dicts.
"""
from agentscope.message import Msg
from agentscope.tool import Toolkit
from pydantic import BaseModel, Field
from ._evolve import FlexReActAgent, format_history, now
from ..base_step import BaseStep
from ...components import R
class MemoryUpdateTask(BaseModel):
"""One daily-note upsert task emitted by the planner."""
path: str = Field(
description="Vault-relative note path, form `daily/<YYYY-MM-DD>/<kebab-case-stem>.md`. "
"Reuse an existing path to upsert.",
)
description: str = Field(description="Flat fact checklist — what to preserve, not how to categorize or format.")
class MemoryUpdatesPlan(BaseModel):
"""Structured output emitted by the planner's finish-tool."""
memory_updates: list[MemoryUpdateTask] = Field(
default_factory=list,
description="List of daily-note upsert tasks; empty means nothing worth persisting.",
)
@R.register("auto_memory_planner_step")
class AutoMemoryPlannerStep(BaseStep):
"""Plan daily-note upsert tasks via a ReAct agent with structured output."""
def __init__(self, console_enabled: bool = False, **kwargs):
super().__init__(**kwargs)
self.console_enabled = console_enabled
self.planner_tools: list[str] = ["daily_list", "read"]
async def execute(self):
assert self.context is not None
messages: list[Msg] = [
item if isinstance(item, Msg) else Msg.from_dict(item) for item in self.context.get("messages", [])
]
memory_hint: str = self.context.get("memory_hint", "")
current = now(self.context.get("timezone"))
if not messages:
self.context.response.success = True
self.context.response.answer = "Skipped: no messages supplied"
self.context.response.metadata.update({"memory_updates": []})
return
toolkit = Toolkit()
for job_name in self.planner_tools:
self.add_as_tool(toolkit, job_name)
agent = FlexReActAgent(
name="auto_memory_planner",
model=self.as_llm,
sys_prompt=self.prompt_format("system_prompt"),
formatter=self.as_llm_formatter,
toolkit=toolkit,
)
agent.set_console_output_enabled(self.console_enabled)
user_message: str = self.prompt_format(
"user_message",
today=current.strftime("%Y-%m-%d"),
vault_dir=str(self.file_store.vault_path),
note=memory_hint or "(none)",
history=format_history(messages),
)
final_msg: Msg = await agent.reply(
Msg(name="reme", role="user", content=user_message),
structured_model=MemoryUpdatesPlan,
)
meta: dict = final_msg.metadata if isinstance(final_msg.metadata, dict) else {}
raw_tasks = meta.get("memory_updates") or []
cleaned: list[dict] = []
for item in raw_tasks:
if not isinstance(item, dict):
continue
path = str(item.get("path") or "").strip()
description = str(item.get("description") or "").strip()
if path and description and path.endswith(".md") and ".." not in path.split("/"):
cleaned.append({"path": path, "description": description})
self.context.response.success = True
self.context.response.metadata.update({"memory_updates": cleaned, "count": len(cleaned)})
if not cleaned:
self.context.response.answer = "[SKIP] No memory updates planned"
return
lines = [f"Planned {len(cleaned)} memory update(s):"]
lines += [f"- {t['path']}: {t['description']}" for t in cleaned]
self.context.response.answer = "\n".join(lines)

View file

@ -1,142 +0,0 @@
system_prompt: |
You are the planner of an auto-memory system. Each invocation's task: inspect the recent conversation, then emit a list of daily-note upsert tasks via the `generate_response` finish tool. You never write notes yourself — a separate writer agent handles that.
user_message: |
Today: {today}
Vault dir: {vault_dir}
Extra hint: {note}
# Recent conversation
{history}
# Your task
Inspect the conversation above and emit a list of daily-note upsert tasks. Follow the five-step workflow below, then submit your planned `memory_updates` by calling `generate_response`.
## Five steps per invocation
### Step 1 — Skip check
Is the conversation a substantive exchange that produced information worth long-term memory — such as user preferences, project decisions, technical facts, workflow knowledge, or status updates? Pure greetings or small talk with no such information → emit an empty `memory_updates` list (the orchestrator treats this as a skip).
When truly ambiguous, default to writing — losing memory is worse than an extra note.
### Step 2 — Survey today
Call `daily_list` to see all existing `daily/<today>/<stem>.md` files along with their `name`, `description`, and other metadata.
### Step 3 — Read candidates
For any existing note whose `name` / `description` looks relevant to this conversation, call `read path=daily/<today>/<stem>.md` to see its body. Skip clearly unrelated notes.
### Step 4 — Plan paths
Decide one or more `(path, description)` pairs. Each `path` is the vault-relative note path of form `daily/<today>/<stem>.md`, where `<stem>` is the filename without the `.md` suffix.
#### 4a. Filename stem naming conventions
The stem is the note's filename without `.md` — its job is to **uniquely identify** this event/topic, not to cram in all context. Constraints:
- **Format**: English kebab-case, composed of nouns / noun phrases, stable as a filename.
- **Length**: Usually 2-5 words, roughly 15-50 characters. **Too short and uninformative** (e.g. `bug`, `chat`, `misc`, `notes-1`) → unusable; **too long with progress/blockers/dates packed in** → unusable, those belong in the body and frontmatter.
- **Information density**: Reading the stem alone should identify which event/topic it refers to; progress, status, and context do not go into the stem.
- **Event type** (specific event / task / outage / debug / release): the stem identifies "which event". E.g.: `auth-middleware-rewrite`, `ingest-pipeline-oom`.
- **Topic type** (ongoing concept / knowledge domain / preference / tool recipe): the stem identifies "which topic". E.g.: `pytorch-distributed-training`, `pr-summary-style`.
#### 4b. Reuse vs. create
- When the conversation continues a thread you found in Step 3 (same logical event, same topic), **reuse the same path** (same stem under today's folder). Fragmenting one thread across multiple paths is the worst failure mode.
- When no existing note covers the topic, **create a new path** of form `daily/<today>/<stem>.md` with the stem following 4a conventions.
- A single conversation may span multiple unrelated topics — emit a separate task for each distinct event/topic rather than merging into one giant task.
#### 4c. What goes in the description
The description only answers "what facts to preserve" — how to format the body, structure sections, or merge updates is the writer's responsibility, not yours.
The description is a flat fact checklist listing everything from the conversation worth preserving. Quote key original wording or numbers verbatim. Do not categorize the facts — categorization into note sections is the writer's job.
Coverage should be comprehensive, including but not limited to:
- Durable facts about the user (role, project, responsibilities, tools, preferences, constraints, goals)
- Domain knowledge (concepts, decision conclusions and rationale, dependency versions, design constraints, system topology)
- Replayable operations (command sequences, script recipes, workflows, debugging steps)
- Current status (progress, blockers, next steps, open questions)
- Timeline events (events that occurred, decision moments)
### Step 5 — Submit
Call `generate_response` with `memory_updates=[{{path, description}}, ...]`. If Step 1 determined a skip, emit `[]`. This is your only way to finish — do not produce free text after Step 4; the structured payload from the finish tool is your entire deliverable.
## Boundaries
- You never write notes — do not call `write`, `edit`, or `frontmatter_update`. Those tools belong to the writer.
- When surveying, stay in today's `daily/` folder — do not traverse the entire vault.
- Every `path` you emit must be of form `daily/<today>/<stem>.md` — never write outside today's daily folder.
- Emit exactly one `memory_updates` list per invocation. Do not call `generate_response` more than once.
system_prompt_zh: |
你是自动记忆系统的规划者。每次调用的任务:研究最近的对话,然后通过 `generate_response` 完成工具发出一份日记 upsert 任务列表。你自己不写笔记——由独立的写入代理负责。
user_message_zh: |
今天:{today}
Vault 目录:{vault_dir}
额外提示:{note}
# 最近的对话
{history}
# 你的任务
研究上面的对话,发出一份日记 upsert 任务列表。按下面的五步流程执行,最后通过调用 `generate_response` 提交计划好的 `memory_updates`。
## 每次调用的五个步骤
### 步骤 1 — 跳过检查
对话是否产生了值得长期记忆的信息——如用户偏好、项目决策、技术事实、工作流知识或状态更新?纯粹的寒暄或闲聊,且未透露任何此类信息 → 发出空的 `memory_updates` 列表(编排器会视为跳过)。
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
### 步骤 2 — 概览今天
调用 `daily_list` 查看所有现存的 `daily/<today>/<stem>.md` 及其 `name`、`description` 和其他 metadata 信息。
### 步骤 3 — 阅读候选
对任何 `name` / `description` 看起来与本次对话相关的现有笔记,调用 `read path=daily/<today>/<stem>.md` 直接阅读正文。明显无关的笔记跳过。
### 步骤 4 — 规划 path
决定一个或多个 `(path, description)` 对。每个 `path` 是 vault 相对路径,形如 `daily/<today>/<stem>.md`;其中 `<stem>` 段就是笔记文件名去掉 `.md` 后缀的部分。
#### 4a. 文件名 stem 命名规范
stem 就是笔记文件名去掉 `.md` 的部分——它的职责是**唯一标识**这条 event/topic,不是塞进所有上下文。约束:
- **格式**:英文 kebab-case,由名词 / 名词短语组成,稳定可作文件名。
- **长度**:通常 2-5 个词,约 15-50 字符。**太短无信息**(如 `bug`、`chat`、`misc`、`notes-1`)→ 不可用;**太长把进度/卡点/日期都塞进去** → 不可用,那些属于正文与 frontmatter。
- **信息含量**:读 stem 即可识别该 event/topic 是什么;进度、状态、上下文不进 stem。
- **event 类型**(具体事件 / 任务 / 故障 / 调试 / 发布):stem 标识"是哪件事"。例:`auth-middleware-rewrite`、`ingest-pipeline-oom`。
- **topic 类型**(持续性的概念 / 知识领域 / 偏好 / 工具配方):stem 标识"是哪类主题"。例:`pytorch-distributed-training`、`pr-summary-style`。
#### 4b. 复用 vs 新建
- 当对话延续一条你在步骤 3 中发现的现有线索时(同一逻辑事件、同一主题),**复用相同的 path**(即今天目录下相同的 stem)。把一条线索碎片化到多个 path 是最严重的失败模式。
- 当没有现存笔记覆盖该主题时,**新建一个形如 `daily/<today>/<stem>.md` 的 path**,stem 段遵循 4a 规范。
- 一次对话可能跨越多个无关主题——为每个不同的 event/topic 各发一个任务,而不是合成一个巨型任务。
#### 4c. description 的内容
description 只负责回答"要保留什么事实"——正文如何分 section、frontmatter 怎么写、UPDATE 如何合并都由写入者处理,不在你的职责内。
description 是一份扁平的事实清单,列出对话中全部值得保留的内容——关键处逐字引用原始措辞或数字。不要对事实做分类——分到笔记 section 是写入者的工作。
覆盖范围要全面,包括但不限于:
- 用户身份相关的持久事实(角色、项目、职责、工具、偏好、约束、目标)
- 领域知识(概念、决策结论与理由、依赖版本、设计约束、系统拓扑)
- 可重放的操作(命令序列、脚本配方、工作流、调试步骤)
- 当下状态(进度、卡点、下一步、未决问题)
- 时间线事件(发生的事件、决策时刻)
### 步骤 5 — 提交
调用 `generate_response`,传入 `memory_updates=[{{path, description}}, ...]`。如果步骤 1 判定跳过,就发出 `[]`。这是你唯一的收尾方式——步骤 4 之后不要再产出自由文本;完成工具的结构化负载就是全部交付物。
## 边界
- 你从不写笔记——不调用 `write`、`edit`、`frontmatter_update`。那些工具属于写入者。
- 概览时只待在今天的 `daily/` 文件夹——不要走遍整个 vault。
- 发出的每个 `path` 必须形如 `daily/<today>/<stem>.md`——绝不写到今天 daily 目录之外。
- 每次调用只产出一份 `memory_updates` 列表。不要多次调用 `generate_response`。

View file

@ -1,89 +0,0 @@
"""``auto_memory_writer`` — execute daily-note upserts.
Reads the ``memory_updates`` list produced by ``auto_memory_planner``
from ``context.response.metadata['memory_updates']``, then iterates
over each ``{path, description}`` task: decides UPDATE vs CREATE by
probing the vault, and writes the note via ``frontmatter_read`` /
``frontmatter_update`` / ``read`` / ``edit`` / ``write``.
A fresh ReAct agent is created per task to keep conversations isolated.
Inputs (from RuntimeContext):
messages (list[Msg], required): conversation slice (context).
memory_hint (str, optional): caller-supplied note hint.
response.metadata['memory_updates'] (list[dict]): planner output.
Output (written to context.response):
answer: one line per task — ``<action> <path>``.
metadata['written_count']: number of tasks executed.
"""
from agentscope.agent import ReActAgent
from agentscope.message import Msg
from agentscope.tool import Toolkit
from ._evolve import format_history, now
from ..base_step import BaseStep
from ...components import R
@R.register("auto_memory_writer_step")
class AutoMemoryWriterStep(BaseStep):
"""Execute note upserts from the planner's task list."""
def __init__(self, console_enabled: bool = False, **kwargs):
super().__init__(**kwargs)
self.console_enabled = console_enabled
self.writer_tools: list[str] = ["frontmatter_read", "frontmatter_update", "read", "edit", "write"]
async def execute(self):
assert self.context is not None
memory_updates: list[dict] = self.context.response.metadata.get("memory_updates") or []
if not memory_updates:
self.context.response.success = True
self.context.response.answer = "[SKIP] No memory updates to write"
return
current = now(self.context.get("timezone"))
messages: list[Msg] = [
item if isinstance(item, Msg) else Msg.from_dict(item) for item in self.context.get("messages", [])
]
memory_hint: str = self.context.get("memory_hint", "")
toolkit = Toolkit()
for job_name in self.writer_tools:
self.add_as_tool(toolkit, job_name)
results: list[str] = []
for task in memory_updates:
note_path = task.get("path", "")
description = task.get("description", "")
if not note_path or not description:
continue
agent = ReActAgent(
name="auto_memory_writer",
model=self.as_llm,
sys_prompt=self.prompt_format("system_prompt"),
formatter=self.as_llm_formatter,
toolkit=toolkit,
)
agent.set_console_output_enabled(self.console_enabled)
user_message: str = self.prompt_format(
"user_message",
today=current.strftime("%Y-%m-%d"),
vault_dir=str(self.file_store.vault_path),
note=memory_hint or "(none)",
note_path=note_path,
writing_hint=description,
history=format_history(messages),
)
final_msg: Msg = await agent.reply(Msg(name="reme", role="user", content=user_message))
result_line = (final_msg.get_text_content() or "").strip()
results.append(result_line)
self.context.response.success = True
self.context.response.answer = "\n".join(results) if results else "[SKIP] No valid tasks"
self.context.response.metadata.update({"written_count": len(results)})

View file

@ -1,167 +0,0 @@
system_prompt: |
You are the writer of the auto-memory system. The planner hands you exactly one target path + description per call; you create or update the corresponding daily note at that path. You never plan new paths or filenames yourself.
user_message: |
Today: {today}
Vault directory: {vault_dir}
Extra hint: {note}
Target path: {note_path}
# Writing hint to reference
{writing_hint}
# Recent conversation
{history}
# Your task
Referring to the hint above, create or update the note at the target path. Follow the four-step process below.
## Output format: body
Your job is to capture the facts from the recent conversation into the body — not a single one may be dropped, nor watered down by paraphrase.
Coverage must be comprehensive, including but not limited to:
- Persistent facts about the user's identity (role, project, responsibilities, tools, preferences, constraints, goals)
- Domain knowledge (concepts, decisions and their rationale, dependency versions, design constraints, system topology)
- Replayable operations (command sequences, script recipes, workflows, debugging steps)
- Current state (progress, blockers, next steps, open questions)
- Timeline events (things that happened, decision moments)
The body format is free-form — use whatever structure best fits the content. The only hard rule is **completeness**: every fact in writing_hint must appear in the body. Quote the original wording or numbers verbatim at critical points.
Merge rules for UPDATE:
- Timeline / history entries: append only — never delete existing entries.
- Current-state entries (progress, blockers, next steps): rewrite the whole section to reflect the latest snapshot.
- Everything else: merge and dedupe (keep all old facts, add new facts, drop exact duplicates).
## Output format: frontmatter
Only two required fields:
```yaml
---
name: <must equal the filename stem of the target path, copied verbatim>
description: <a detailed summary of the body — specific enough that the description alone conveys all the core information in the note>
---
```
Rules:
- `name` must be the filename stem of the target path (the part between the last `/` and `.md`), copied verbatim. Do not Title-Case it; do not rewrite it.
- `description` must be an exhaustive summary: mention every key fact, decision, and state point in the body, so the description itself works as a reliable index entry. Vague descriptions like "notes" / "misc" / "various topics" are unacceptable.
- **Never set `status`** — it is a field reserved for the downstream distill stage; touching it will cause the note to be skipped on the next distill run.
- On UPDATE, refresh `description` to reflect the updated body.
## The four steps for each call
### Step 1 — Probe
Call `frontmatter_read path=<target path>`:
- Returns frontmatter dict → **UPDATE branch** (note already exists)
- Returns error / not-found → **CREATE branch** (does not yet exist)
### Step 2 — UPDATE branch
1. `read path=<target path>` to view the current body.
2. Plan the merge per the rules above (timeline appends, facts merge-and-dedupe, current-state rewritten in full).
3. Execute the write — **prefer `edit` over `write`**; only fall back to `write` when the change is genuinely too sweeping for `edit` to express cleanly:
- **Default: `edit`** — `edit path=<target path> old=<original snippet> new=<replacement snippet>` for each changed section (frontmatter is unaffected). Use multiple `edit` calls if needed to cover several sections.
- After any body change, you must also refresh frontmatter → `frontmatter_update path=<target path> metadata={{"description": "<updated summary>"}}`.
- **Fallback: `write`** — only when the body has changed so extensively that multiple edits would be harder to get right than a full rewrite → `write path=<target path> name=<name> description=<description> content=<full body>`, resetting body and frontmatter in one shot.
### Step 2' — CREATE branch
1. Write the full body, capturing every fact from writing_hint.
2. Write the frontmatter with `name` and `description`.
3. `write path=<target path> name=<name> description=<description> content=<body content>` — done in one call.
### Step 3 — Summarize what changed
State in one sentence what you did (which file you created / what you updated). This sentence is your final text output and must be strictly a single line.
## Boundaries
- Each call targets exactly one path: the one assigned. Even if the conversation mentions other notes, do not touch them.
- `write` unconditionally overwrites both body and frontmatter — use it carefully.
system_prompt_zh: |
你是自动记忆系统的写入者。规划者每次调用会交给你恰好一个目标路径 + description;你在该路径创建或更新对应的日记。你从不规划新的路径或文件名。
user_message_zh: |
今天:{today}
Vault 目录:{vault_dir}
额外提示:{note}
目标路径:{note_path}
# 可以参考的提示
{writing_hint}
# 最近的对话
{history}
# 你的任务
参考上面的提示,在目标路径创建或更新日记。按下面的四步流程执行。
## 输出格式要求:正文
你的职责是捕捉最近的对话中的事实写入正文——一条都不能漏,也不能被改写淡化。
覆盖范围要全面,包括但不限于:
- 用户身份相关的持久事实(角色、项目、职责、工具、偏好、约束、目标)
- 领域知识(概念、决策结论与理由、依赖版本、设计约束、系统拓扑)
- 可重放的操作(命令序列、脚本配方、工作流、调试步骤)
- 当下状态(进度、卡点、下一步、未决问题)
- 时间线事件(发生的事件、决策时刻)
正文格式自由——用最适合内容的结构。唯一的硬性规则是**完整性**:writing_hint 中的每一条事实都必须出现在正文中。关键处逐字引用原始措辞或数字。
UPDATE 时的合并规则:
- 时间线 / 历史条目:仅追加,永远不删除已有条目。
- 当下状态类条目(进度、卡点、下一步):整段重写,反映最新快照。
- 其余内容:合并去重(保留全部旧事实,添加新事实,去除完全重复项)。
## 输出格式要求:frontmatter
只有两个必填字段:
```yaml
---
name: <必须等于目标路径的文件名 stem,逐字照抄>
description: <正文的详细总结——具体到仅凭 description 就能传达笔记中的全部核心信息>
---
```
规则:
- `name` 必须是目标路径的文件名 stem(最后一个 `/` 与 `.md` 之间的部分),逐字照抄。不要 Title-Case 化,不要改写。
- `description` 必须是详尽的总结:提及正文中的每一个关键事实、决策和状态要点,使 description 本身就能作为可靠的索引条目。模糊的描述如 "notes" / "misc" / "各种主题" 不可接受。
- **永远不要设置 `status`**——它是下游蒸馏阶段保留的字段;动了它会让笔记在下一次 distill 运行中被忽略。
- UPDATE 时,刷新 `description` 以反映更新后的正文内容。
## 每次调用的四个步骤
### 步骤 1 — 探测
调用 `frontmatter_read path=<目标路径>`:
- 返回 frontmatter 字典 → **UPDATE 分支**(笔记已存在)
- 返回错误 / not-found → **CREATE 分支**(尚不存在)
### 步骤 2 — UPDATE 分支
1. `read path=<目标路径>` 查看当前正文。
2. 按上述规则规划合并(时间线追加,事实合并去重,当下状态整段重写)。
3. 执行写入——**优先使用 `edit`,而非 `write`**;仅当变更范围确实过大、edit 难以清晰表达时才退回到 `write`:
- **默认:`edit`** — `edit path=<目标路径> old=<原文片段> new=<替换片段>`,对每个变更区域分别调用(frontmatter 不受影响)。需要时可多次调用 `edit` 覆盖多个区域。
- 正文变更后,必须同时刷新 frontmatter → `frontmatter_update path=<目标路径> metadata={{"description": "<更新后的总结>"}}`。
- **退路:`write`** — 仅当正文改动极大、多次 edit 反而更难准确操作时 → `write path=<目标路径> name=<name> description=<description> content=<完整正文>`,一次性重置正文和 frontmatter。
### 步骤 2' — CREATE 分支
1. 编写完整正文,捕捉 writing_hint 中的全部事实。
2. 编写 frontmatter,包含 `name` 和 `description`。
3. `write path=<目标路径> name=<name> description=<description> content=<正文内容>`——一次性完成。
### 步骤 3 — 总结改动内容
用一句话说明你做了什么(创建了哪个文件 / 更新了哪些内容)。这句话是你最后一次文本输出,必须严格只有一行。
## 边界
- 每次调用只针对一个目标路径:被分配的那个。即使对话里提到其他笔记,也不要碰。
- `write` 会无条件覆盖正文和frontmatter,请谨慎使用。

View file

@ -1,18 +1,22 @@
"""``daily_create`` — provision a session note under a daily folder: ``daily/<date>/<session_id>.md``.
"""``daily_create`` — provision a session note under a daily folder.
Validates the session_id, mkdirs the day folder, writes an empty-body
note with frontmatter ``{name: session_id}`` if (and only if) the file
does not already exist, refreshes the day index, and returns the
When ``session_id`` is provided: ``daily/<date>/<session_id>.md``
When ``session_id`` is empty: ``daily/<date>.md`` (the day-level file)
Validates the session_id (when non-empty), mkdirs the day folder,
writes an empty-body note with frontmatter ``{name, description}``
if (and only if) the file does not already exist, refreshes the day
index (only when session_id is non-empty), and returns the
vault-relative path.
Idempotent: when the note already exists this is a no-op write (the
day index still refreshes — siblings may have changed; cheap
self-healing). The caller fills the body via ``file_write`` /
``file_edit`` / ``file_append`` (or a native editor); ``daily_create``
deliberately does not accept a body.
Idempotent: when the note already exists this is a no-op write.
The caller fills the body via ``file_write`` / ``file_edit`` /
``file_append`` (or a native editor); ``daily_create`` deliberately
does not accept a body.
Inputs:
session_id (required, validated) — the note's session identifier (also the file stem)
session_id (optional) — the note's session identifier (also the file stem);
empty string → day-level file
date (optional, ``YYYY-MM-DD``; empty = today)
Outputs:
@ -32,7 +36,7 @@ from ...components import R
@R.register("daily_create_step")
class DailyCreateStep(BaseStep):
"""Provision ``daily/<date>/<session_id>.md`` (idempotent); refresh day index."""
"""Provision a daily note (idempotent); refresh day index when applicable."""
def _fail(self, message: str, **meta) -> None:
"""Mark response failed; copy ``meta`` into ``response.metadata``."""
@ -51,16 +55,16 @@ class DailyCreateStep(BaseStep):
return session_id, day, daily_dir
@staticmethod
def _empty_note_text(session_id: str) -> str:
def _empty_note_text(name: str) -> str:
"""Serialize an empty-body markdown note with frontmatter ``{name, description}``; trailing newline."""
text = frontmatter.dumps(frontmatter.Post("", name=session_id, description=""))
text = frontmatter.dumps(frontmatter.Post("", name=name, description=""))
return text if text.endswith("\n") else text + "\n"
async def _create_if_missing(self, path_abs: Path, session_id: str) -> bool:
async def _create_if_missing(self, path_abs: Path, name: str) -> bool:
"""Write the empty note only when the file is absent. Returns ``True`` iff a new file was created."""
if path_abs.is_file():
return False
await write_file_safe(path_abs, self._empty_note_text(session_id), encoding="utf-8")
await write_file_safe(path_abs, self._empty_note_text(name), encoding="utf-8")
return True
def _set_success(self, payload: dict, created: bool) -> None:
@ -71,27 +75,32 @@ class DailyCreateStep(BaseStep):
self.context.response.metadata.update(payload)
async def execute(self):
"""Validate the session_id, provision the note file, refresh the day index, stamp the response."""
"""Provision the note file, optionally refresh the day index, stamp the response."""
assert self.context is not None
session_id, day, daily_dir = self._collect_params()
err = validate_session_id(session_id)
if err:
self._fail(err)
return None
if session_id:
err = validate_session_id(session_id)
if err:
self._fail(err)
return None
path_rel = f"{daily_dir}/{day}/{session_id}.md"
name = session_id
else:
path_rel = f"{daily_dir}/{day}.md"
name = day
path_rel = f"{daily_dir}/{day}/{session_id}.md"
path_abs = (self.vault_path / path_rel).resolve()
try:
created = await self._create_if_missing(path_abs, session_id)
created = await self._create_if_missing(path_abs, name)
except Exception as e: # pylint: disable=broad-except
self._fail(f"create failed: {e}", date=day, session_id=session_id, path=path_rel)
return None
index = await refresh_day_index(self.file_store, day, daily_dir)
self._set_success(
{"date": day, "session_id": session_id, "path": path_rel, "created": created, "index": index},
created,
)
payload: dict = {"date": day, "session_id": session_id, "path": path_rel, "created": created}
if session_id:
payload["index"] = await refresh_day_index(self.file_store, day, daily_dir)
self._set_success(payload, created)
self.logger.info(f"[{self.name}] {'created' if created else 'reused'} path={path_rel}")
return self.context.response

View file

@ -1,16 +1,16 @@
"""Integration test for the auto_memory job (planner + writer end-to-end).
"""Integration test for the auto_memory job (single-step).
Drives the full ``auto_memory`` orchestrator against a real LLM. The scenario
seeds one existing daily note covering an ongoing event, then feeds a
10-message conversation that:
Drives the ``auto_memory`` step against a real LLM. Two scenarios:
1. continues the existing event with new status / facts (expects an UPDATE
that preserves the old facts and appends the new ones), and
2. introduces a brand-new topic (expects a CREATE under today's daily folder
with a stem distinct from the seeded one).
1. **CREATE**: calls ``auto_memory`` with a fresh ``session_id`` and
conversation messages. Expects a new note with the key facts.
2. **UPDATE**: seeds an existing daily note, calls ``auto_memory`` with
the same ``session_id`` and new conversation messages. Expects the
old facts to survive and new facts to land.
Requires LLM_API_KEY (and optionally LLM_BASE_URL / LLM_MODEL_NAME) in the
environment or a .env file at the repo root. Hits the real Anthropic API.
environment or a .env file at the repo root. Hits the real LLM API.
"""
import asyncio
@ -28,10 +28,8 @@ from reme4.utils import load_env
load_env()
# Where agent.memory jsonl dumps land — same directory as this test file.
DUMP_DIR = Path(__file__).resolve().parent
SEED_STEM = "auth-middleware-rewrite"
SEED_BODY = """---
name: auth-middleware-rewrite
@ -92,8 +90,8 @@ def _seed_note(vault_root: Path, today: str) -> Path:
return path
def _make_messages() -> list[dict]:
"""A 10-turn conversation: first half continues the auth event, second half opens a new topic."""
def _auth_messages() -> list[dict]:
"""Messages continuing the auth middleware thread."""
return [
{
"name": "user",
@ -125,12 +123,16 @@ def _make_messages() -> list[dict]:
"对,下一步:周五 2026-05-29 前把 redis 配置改成 volatile-ttl 并重测," "blocked 在 SRE @lihua 的排期。"
),
},
]
def _pytorch_messages() -> list[dict]:
"""Messages about a brand-new pytorch topic."""
return [
{
"name": "user",
"role": "user",
"content": (
"切个话题,最近在调 pytorch 分布式训练。结论:DDP 启动推荐用 torchrun," "比 mp.spawn 稳很多。"
),
"content": ("最近在调 pytorch 分布式训练。结论:DDP 启动推荐用 torchrun," "比 mp.spawn 稳很多。"),
},
{
"name": "assistant",
@ -156,7 +158,7 @@ def _make_messages() -> list[dict]:
{
"name": "user",
"role": "user",
"content": "这两件事都先记一下。",
"content": "先记一下。",
},
]
@ -168,8 +170,7 @@ def _read_text(p: Path) -> str:
class _AgentMemoryRecorder:
"""Monkey-patches ReActAgent.__init__ to capture every agent created inside
the ``with`` block, then dumps each agent's memory to a jsonl file in
DUMP_DIR on exit. One file per agent: ``agent_memory_<idx>_<name>.jsonl``,
one message per line as ``Msg.to_dict()``.
DUMP_DIR on exit.
"""
def __init__(self, dump_dir: Path, prefix: str = "agent_memory"):
@ -194,12 +195,11 @@ class _AgentMemoryRecorder:
return self
def __exit__(self, *exc):
"""Restore the original __init__ and dump all agent memories."""
"""Restore the original __init__."""
ReActAgent.__init__ = self._orig_init
async def dump(self) -> list[Path]:
"""Dump all agent memories."""
# Wipe any prior dumps from this prefix so reruns don't accumulate stale files.
for stale in self.dump_dir.glob(f"{self.prefix}_*.jsonl"):
stale.unlink()
@ -214,8 +214,8 @@ class _AgentMemoryRecorder:
return self.dumped_paths
def test_auto_memory_updates_existing_and_creates_new():
"""End-to-end: planner survey + UPDATE one seeded note + CREATE one new note."""
def test_auto_memory_create():
"""CREATE a new note from scratch with a fresh session_id."""
async def run():
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
@ -223,103 +223,33 @@ def test_auto_memory_updates_existing_and_creates_new():
try:
vault_root = Path(app.config.vault_dir).absolute()
today = _today()
seed_path = _seed_note(vault_root, today)
seed_before = _read_text(seed_path)
assert "legal/compliance" in seed_before
day_dir = vault_root / "daily" / today
files_before = {p.name for p in day_dir.glob("*.md")}
assert files_before == {f"{SEED_STEM}.md"}
messages = _make_messages()
print("\n" + "=" * 70)
print("[setup] vault_root =", vault_root)
print("[setup] today =", today)
print("[setup] seed_path =", seed_path)
print(f"[setup] seed body ({len(seed_before)} bytes):\n{seed_before}")
print(f"[setup] feeding {len(messages)} messages to auto_memory:")
for i, m in enumerate(messages, 1):
print(f" {i:2d}. [{m['role']}] {m['content']}")
print("=" * 70)
with _AgentMemoryRecorder(DUMP_DIR) as recorder:
response = await app.run_job("auto_memory", messages=messages)
pytorch_session_id = "pytorch-distributed-training"
with _AgentMemoryRecorder(DUMP_DIR, prefix="agent_create") as recorder:
response = await app.run_job(
"auto_memory",
messages=_pytorch_messages(),
session_id=pytorch_session_id,
)
dumped = await recorder.dump()
print(f"\n[dump] captured {len(recorder.agents)} agent(s); wrote {len(dumped)} jsonl file(s):")
for p in dumped:
print(f" - {p}")
print(f"[CREATE] agent memory dumped: {p}")
# --- response-level assertions -------------------------------
assert response.success is True, f"job failed: {response.answer!r}"
assert response.success is True, f"CREATE job failed: {response.answer!r}"
meta = response.metadata or {}
memory_updates = meta.get("memory_updates") or []
assert meta.get("created") is True, f"Expected created=True, got {meta!r}"
assert meta.get("path") == f"daily/{today}/{pytorch_session_id}.md"
print("\n" + "=" * 70)
print(f"[planner] planned {len(memory_updates)} update(s):")
for u in memory_updates:
print(f" - path: {u.get('path')}")
print(f" description: {u.get('description')}")
print(f"\n[writer] written_count = {meta.get('written_count')}")
print(f"[writer] answer:\n{response.answer}")
print("=" * 70)
pytorch_path = vault_root / meta["path"]
assert pytorch_path.is_file(), f"created note not found at {pytorch_path}"
assert (
len(memory_updates) >= 2
), f"expected at least 2 planned updates (1 UPDATE + 1 CREATE), got {memory_updates}"
# Every emitted path must live under today's daily folder.
paths = [u["path"] for u in memory_updates]
for p in paths:
assert p.startswith(f"daily/{today}/") and p.endswith(
".md",
), f"path {p!r} violates daily/<today>/<stem>.md shape"
# --- UPDATE branch: seeded note ------------------------------
update_path_str = f"daily/{today}/{SEED_STEM}.md"
assert (
update_path_str in paths
), f"planner did not reuse the seeded path {update_path_str!r}; got {paths}"
seed_after = _read_text(seed_path)
print("\n" + "=" * 70)
print(f"[UPDATE] {seed_path} ({len(seed_before)} → {len(seed_after)} bytes)")
print(f"[UPDATE] body after:\n{seed_after}")
print("=" * 70)
# Old facts must survive (timeline append + facts merge-and-dedupe).
for old_fact in ("legal/compliance", "RS256", "Alice"):
assert (
old_fact in seed_after
), f"UPDATE dropped pre-existing fact {old_fact!r}\n--- AFTER ---\n{seed_after}"
# At least some of the new facts from the conversation must land.
new_hits = [
needle
for needle in ("PR #432", "432", "volatile-ttl", "maxmemory-policy", "2026-05-29")
if needle in seed_after
]
print("[UPDATE] preserved old facts: ['legal/compliance', 'RS256', 'Alice']")
print(f"[UPDATE] landed new facts: {new_hits}")
assert (
len(new_hits) >= 2
), f"UPDATE only landed {new_hits!r} of expected new facts\n--- AFTER ---\n{seed_after}"
# --- CREATE branch: new topic --------------------------------
files_after = {p.name for p in day_dir.glob("*.md")}
new_files = files_after - files_before
print(f"\n[CREATE] new files under daily/{today}/: {sorted(new_files)}")
assert new_files, f"no new note created under daily/{today}/; planner paths: {paths}"
# Find the file that actually covers the pytorch topic.
pytorch_path: Path | None = None
for fname in new_files:
text = _read_text(day_dir / fname)
if any(kw in text for kw in ("torchrun", "NCCL", "pytorch", "mp.spawn")):
pytorch_path = day_dir / fname
break
assert pytorch_path is not None, f"no created note covers the pytorch topic; new files: {new_files}"
pytorch_text = _read_text(pytorch_path)
print("=" * 70)
print("\n" + "=" * 70)
print(f"[CREATE] {pytorch_path} ({len(pytorch_text)} bytes)")
print(f"[CREATE] body:\n{pytorch_text}")
print("=" * 70)
@ -332,16 +262,79 @@ def test_auto_memory_updates_existing_and_creates_new():
print(f"[CREATE] landed topic facts: {topic_hits}")
assert (
len(topic_hits) >= 3
), f"CREATE only captured {topic_hits!r} of expected new-topic facts\n--- CREATE ---\n{pytorch_text}"
# frontmatter sanity: name should equal the file stem, description non-empty.
), f"CREATE only captured {topic_hits!r} of expected facts\n--- CREATE ---\n{pytorch_text}"
stem = pytorch_path.stem
assert (
f"name: {stem}" in pytorch_text
), f"frontmatter name does not match stem {stem!r}\n{pytorch_text[:400]}"
print(f"[CREATE] frontmatter name matches stem {stem!r}")
print("\n" + "=" * 70)
print("✓ test_auto_memory_updates_existing_and_creates_new passed")
print("test_auto_memory_create passed")
print("=" * 70)
finally:
await app.close()
asyncio.run(run())
def test_auto_memory_update():
"""UPDATE an existing note — old facts must survive, new facts must land."""
async def run():
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
app = await _make_app()
try:
vault_root = Path(app.config.vault_dir).absolute()
today = _today()
seed_path = _seed_note(vault_root, today)
seed_before = _read_text(seed_path)
assert "legal/compliance" in seed_before
print("\n" + "=" * 70)
print("[setup] vault_root =", vault_root)
print("[setup] today =", today)
print("[setup] seed_path =", seed_path)
print("=" * 70)
with _AgentMemoryRecorder(DUMP_DIR, prefix="agent_update") as recorder:
response = await app.run_job(
"auto_memory",
messages=_auth_messages(),
session_id=SEED_STEM,
)
dumped = await recorder.dump()
for p in dumped:
print(f"[UPDATE] agent memory dumped: {p}")
assert response.success is True, f"UPDATE job failed: {response.answer!r}"
meta = response.metadata or {}
assert meta.get("created") is False, f"Expected created=False, got {meta!r}"
assert meta.get("path") == f"daily/{today}/{SEED_STEM}.md"
seed_after = _read_text(seed_path)
print("\n" + "=" * 70)
print(f"[UPDATE] {seed_path} ({len(seed_before)} -> {len(seed_after)} bytes)")
print(f"[UPDATE] body after:\n{seed_after}")
print("=" * 70)
for old_fact in ("legal/compliance", "RS256", "Alice"):
assert (
old_fact in seed_after
), f"UPDATE dropped pre-existing fact {old_fact!r}\n--- AFTER ---\n{seed_after}"
new_hits = [
needle
for needle in ("PR #432", "432", "volatile-ttl", "maxmemory-policy", "2026-05-29")
if needle in seed_after
]
print(f"[UPDATE] preserved old facts, landed new facts: {new_hits}")
assert (
len(new_hits) >= 2
), f"UPDATE only landed {new_hits!r} of expected new facts\n--- AFTER ---\n{seed_after}"
print("\n" + "=" * 70)
print("test_auto_memory_update passed")
print("=" * 70)
finally:
await app.close()
@ -351,5 +344,6 @@ def test_auto_memory_updates_existing_and_creates_new():
if __name__ == "__main__":
print("=== auto_memory integration test ===")
test_auto_memory_updates_existing_and_creates_new()
test_auto_memory_create()
test_auto_memory_update()
print("\nAll integration tests passed!")

View file

@ -382,18 +382,22 @@ def test_daily_create_rejects_invalid_session_id():
asyncio.run(run())
def test_daily_create_rejects_empty_session_id():
"""Empty / missing session_id is rejected with a clear message."""
def test_daily_create_empty_session_id_creates_day_level_file():
"""Empty session_id creates day-level file ``daily/<date>.md``."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store_with_dailies([])
step = daily_create_step.DailyCreateStep(file_store=store)
await step(session_id="", date="2026-05-18")
assert step.context.response.success is False
assert "session_id" in (step.context.response.answer or "").lower()
assert step.context.response.success is True
meta = step.context.response.metadata
assert meta["path"] == "daily/2026-05-18.md"
assert meta["session_id"] == ""
assert meta["created"] is True
assert Path(tmp, "daily", "2026-05-18.md").is_file()
await store.close()
print("✓ test_daily_create_rejects_empty_session_id passed")
print("✓ test_daily_create_empty_session_id_creates_day_level_file passed")
asyncio.run(run())
@ -610,7 +614,7 @@ if __name__ == "__main__":
test_daily_create_default_date_is_today()
test_daily_create_default_frontmatter_uses_session_id_as_name()
test_daily_create_rejects_invalid_session_id()
test_daily_create_rejects_empty_session_id()
test_daily_create_empty_session_id_creates_day_level_file()
test_daily_create_then_skip_round_trip()
test_day_index_lists_each_note()
test_day_index_includes_note_descriptions()