diff --git a/pyproject.toml b/pyproject.toml index 19d853f5..3636193a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/reme4/config/default.yaml b/reme4/config/default.yaml index 4ae80805..5583b08e 100644 --- a/reme4/config/default.yaml +++ b/reme4/config/default.yaml @@ -109,19 +109,18 @@ jobs: daily_create: backend: base - description: "Provision a session note under a daily folder: daily//.md" + description: "Provision a session note under a daily folder: daily//.md or daily/.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: diff --git a/reme4/steps/__init__.py b/reme4/steps/__init__.py index afd7bbb0..72fab3b4 100644 --- a/reme4/steps/__init__.py +++ b/reme4/steps/__init__.py @@ -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", diff --git a/reme4/steps/evolve/auto_memory.py b/reme4/steps/evolve/auto_memory.py new file mode 100644 index 00000000..4b70e311 --- /dev/null +++ b/reme4/steps/evolve/auto_memory.py @@ -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}) diff --git a/reme4/steps/evolve/auto_memory.yaml b/reme4/steps/evolve/auto_memory.yaml new file mode 100644 index 00000000..1192696b --- /dev/null +++ b/reme4/steps/evolve/auto_memory.yaml @@ -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= description= content=` + + - `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= 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= new=` 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": ""}}`. + 3. If `edit` fails repeatedly (e.g., cannot find the original text due to formatting mismatch), fall back to `write path={note_path} name= description= content=` 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= description= content=` + + - `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= description= content=<完整正文>` 全量重写。 + + ## 步骤 3b — 全量写入(空文件 fallback) + + 文件存在但正文为空。一次性写入完整内容: + `write path={note_path} name= description= content=<正文>` + + - `name` 必须等于目标路径的文件名 stem(最后一个 `/` 与 `.md` 之间的部分),逐字照抄。 + - `description` 必须是正文的详尽总结——具体到仅凭 description 就能传达全部核心信息。 + + ## 步骤 4 — 总结 + + 用一句话说明你做了什么(更新了哪些内容)。这是你最后一次文本输出。 + + ## 边界 + + - 只针对一个目标路径:`{note_path}`。不要碰其他笔记。 + - `write` 会无条件覆盖正文和 frontmatter,请谨慎使用。 diff --git a/reme4/steps/evolve/auto_memory_planner.py b/reme4/steps/evolve/auto_memory_planner.py deleted file mode 100644 index e084b672..00000000 --- a/reme4/steps/evolve/auto_memory_planner.py +++ /dev/null @@ -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//.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) diff --git a/reme4/steps/evolve/auto_memory_planner.yaml b/reme4/steps/evolve/auto_memory_planner.yaml deleted file mode 100644 index 3e6b7284..00000000 --- a/reme4/steps/evolve/auto_memory_planner.yaml +++ /dev/null @@ -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//.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//.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//.md`, where `` 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//.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//.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//.md` 及其 `name`、`description` 和其他 metadata 信息。 - - ### 步骤 3 — 阅读候选 - 对任何 `name` / `description` 看起来与本次对话相关的现有笔记,调用 `read path=daily//.md` 直接阅读正文。明显无关的笔记跳过。 - - ### 步骤 4 — 规划 path - - 决定一个或多个 `(path, description)` 对。每个 `path` 是 vault 相对路径,形如 `daily//.md`;其中 `` 段就是笔记文件名去掉 `.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//.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//.md`——绝不写到今天 daily 目录之外。 - - 每次调用只产出一份 `memory_updates` 列表。不要多次调用 `generate_response`。 diff --git a/reme4/steps/evolve/auto_memory_writer.py b/reme4/steps/evolve/auto_memory_writer.py deleted file mode 100644 index 6c0fbf4b..00000000 --- a/reme4/steps/evolve/auto_memory_writer.py +++ /dev/null @@ -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 — `` ``. - 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)}) diff --git a/reme4/steps/evolve/auto_memory_writer.yaml b/reme4/steps/evolve/auto_memory_writer.yaml deleted file mode 100644 index fc0f3912..00000000 --- a/reme4/steps/evolve/auto_memory_writer.yaml +++ /dev/null @@ -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: - description: - --- - ``` - - 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=`: - - 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=` 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= old= new=` 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= metadata={{"description": ""}}`. - - **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= name= description= content=`, 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= name= description= 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= description= content=<完整正文>`,一次性重置正文和 frontmatter。 - - ### 步骤 2' — CREATE 分支 - 1. 编写完整正文,捕捉 writing_hint 中的全部事实。 - 2. 编写 frontmatter,包含 `name` 和 `description`。 - 3. `write path=<目标路径> name= description= content=<正文内容>`——一次性完成。 - - ### 步骤 3 — 总结改动内容 - 用一句话说明你做了什么(创建了哪个文件 / 更新了哪些内容)。这句话是你最后一次文本输出,必须严格只有一行。 - - ## 边界 - - - 每次调用只针对一个目标路径:被分配的那个。即使对话里提到其他笔记,也不要碰。 - - `write` 会无条件覆盖正文和frontmatter,请谨慎使用。 diff --git a/reme4/steps/file_io/daily_create.py b/reme4/steps/file_io/daily_create.py index 800e0156..bf429e9b 100644 --- a/reme4/steps/file_io/daily_create.py +++ b/reme4/steps/file_io/daily_create.py @@ -1,18 +1,22 @@ -"""``daily_create`` — provision a session note under a daily folder: ``daily//.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//.md`` +When ``session_id`` is empty: ``daily/.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//.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 diff --git a/tests4/integration/test_auto_memory.py b/tests4/integration/test_auto_memory.py index 88ec0eb6..2af6ca74 100644 --- a/tests4/integration/test_auto_memory.py +++ b/tests4/integration/test_auto_memory.py @@ -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__.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//.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!") diff --git a/tests4/unit/test_daily_steps.py b/tests4/unit/test_daily_steps.py index 8036ca39..b194be47 100644 --- a/tests4/unit/test_daily_steps.py +++ b/tests4/unit/test_daily_steps.py @@ -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/.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()