ReMe/reme/steps/evolve/auto_memory.py
jinliyl ffb4d08c4f
Some checks are pending
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
feat(mem): Enhance daily note system with metadata handling and write functionality (#295)
* feat(file_io): add daily_write step for creating daily notes with conversation metadata

- Add DailyWriteStep class that delegates to write job for creating daily notes
- Register daily_write job in default configuration with proper parameters
- Include validation for name and session_id path components
- Add test coverage for daily_write functionality including metadata handling
- Preserve existing job execution method in application.py after repositioning
- Update base_step.py to use positional-only parameter syntax for job methods
- Import and expose DailyWriteStep in file_io module initialization
- Override reserved metadata keys (name, description, session_id, source_conversation) with fixed values
- Refresh daily index after successful write operation
- Generate proper source conversation links in markdown format

* feat(daily): refactor daily note system with enhanced metadata handling

- Introduce validate_filename_component function and export it
- Add _INDEX_HIDDEN_METADATA_KEYS to hide conversation metadata from index
- Update scan_notes to exclude hidden metadata keys from index rendering
- Modify auto_memory to use daily_write tool and manage session frontmatter
- Implement session note lookup and renaming based on frontmatter name
- Update daily_list to return flattened note metadata including session info
- Change daily_write to dispatch write step instead of running job
- Add test cases for updated daily note functionality and metadata handling
- Update version from 0.4.0.2 to 0.4.0.3

* fix(evolve): correct metadata update in auto memory response

- Fixed trailing comma issue in metadata dictionary update
- Ensured proper formatting of response metadata structure
- Maintained existing functionality while fixing syntax error

* refactor(auto_resource): replace daily_create with dynamic note management

- Remove DailyCreateStep and related exports from file_io module
- Replace static daily note creation with dynamic resource-linked card system
- Implement LLM-suggested naming with frontmatter-driven file management
- Add source_resource linking for tracking original files
- Introduce collision handling with hash-based suffixes
- Update documentation to reflect new resource card workflow
- Modify auto_resource prompts to use write/edit tools instead of daily_create
- Adjust test fixture comments to match new agent behavior
- Update framework diagrams and quick start examples accordingly

* feat(app): add version info to app initialization and update auto-memory logic

- Include version number in application startup logging
- Remove tool result truncation logic from auto-memory step
- Update auto-memory to exclude tool_result blocks from saved history
- Add test case to verify tool results are filtered out from message saving
- Update YAML prompts to clarify filename naming rules without dates
- Modify configuration to support new dispatch steps format with persistence control

* feat(auto_memory): add note modification tracking and optimize frontmatter updates

- Add _note_bytes and _note_modified methods to track actual file changes
- Optimize frontmatter updates by checking existing metadata before update
- Add modified flag to response metadata indicating actual note changes
- Update logging to include modified status in various operations
- Add comprehensive tests for modified/unmodified detection scenarios
- Enhance result hook logic to skip when no actual changes occur
- Refactor metadata handling to properly track creation vs modification status
2026-06-25 21:54:56 +08:00

325 lines
14 KiB
Python

"""auto_memory — record conversation facts into a daily note via an agent."""
from pathlib import Path
import aiofiles
import frontmatter
from agentscope.message import Msg
from ._evolve import agent_reply_result_text, format_history, now
from ..base_step import BaseStep
from ..file_io import refresh_day_index, validate_filename_component, validate_session_id
from ...components import R
_SESSION_ID_KEY = "session_id"
_SOURCE_CONVERSATION_KEY = "source_conversation"
def _sanitize_msg_for_save(msg: Msg) -> Msg:
new_content = []
changed = False
for block in msg.content:
# Tool results often contain recalled memory/search/read output. Keeping
# them in saved conversation history lets retrieved facts masquerade as
# user-provided context in future auto-memory runs.
if block.type == "tool_result":
changed = True
continue
if block.type == "data" and hasattr(block, "source") and getattr(block.source, "type", None) == "base64":
changed = True
continue
new_content.append(block)
if not changed:
return msg
return msg.model_copy(update={"content": new_content})
@R.register("auto_memory_step")
class AutoMemoryStep(BaseStep):
"""Record conversation facts into a daily note via an Agent."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.create_tools: list[str] = ["daily_write"]
self.update_tools: list[str] = ["read", "edit", "frontmatter_update", "write"]
def _session_dir(self) -> str:
return str(self.config_value("session_dir")).strip("/")
def _session_path(self, session_id: str) -> Path:
return self.file_store.workspace_path / self._session_dir() / "dialog" / f"{session_id}.jsonl"
def _session_link(self, session_id: str) -> str:
return f"[[{self._session_dir()}/dialog/{session_id}.jsonl]]"
def _daily_note_path(self, day: str, name: str) -> str:
return f"{self.config_value('daily_dir')}/{day}/{name}.md"
def _frontmatter(self, path: str) -> dict:
post = frontmatter.loads((self.file_store.workspace_path / path).read_text(encoding="utf-8"))
return dict(post.metadata or {})
def _note_bytes(self, path: str) -> bytes | None:
note_path = self.file_store.workspace_path / path
if not note_path.is_file():
return None
return note_path.read_bytes()
def _note_modified(self, before_path: str, before_bytes: bytes | None, after_path: str) -> bool:
if not after_path:
return False
after_bytes = self._note_bytes(after_path)
if after_bytes is None:
return before_bytes is not None
return after_path != before_path or before_bytes != after_bytes
def _find_session_note(self, notes: list[dict], session_id: str) -> dict | None:
source = self._session_link(session_id)
for note in notes:
if str(note.get(_SESSION_ID_KEY, "")).strip() == session_id:
return note
for note in notes:
if str(note.get(_SOURCE_CONVERSATION_KEY, "")).strip() == source:
return note
return None
async def _list_session_note(self, day: str, session_id: str) -> dict | None:
list_response = await self.run_job("daily_list", date=day)
if not list_response.success:
raise RuntimeError(f"daily_list failed: {list_response.answer}")
notes = list_response.metadata.get("notes") or []
return self._find_session_note(notes, session_id)
async def _ensure_session_frontmatter(self, path: str, session_id: str) -> None:
metadata = {
_SESSION_ID_KEY: session_id,
_SOURCE_CONVERSATION_KEY: self._session_link(session_id),
}
current = self._frontmatter(path)
if all(current.get(key) == value for key, value in metadata.items()):
return
response = await self.run_job(
"frontmatter_update",
path=path,
metadata=metadata,
)
if not response.success:
raise RuntimeError(f"frontmatter_update failed: {response.answer}")
async def _rename_from_frontmatter_name(self, path: str, day: str) -> str:
meta = self._frontmatter(path)
name = str(meta.get("name", "")).strip()
if not name:
return path
if err := validate_filename_component(name, kind="name"):
raise RuntimeError(err)
target_path = self._daily_note_path(day, name)
if target_path == path:
return path
move_response = await self.run_job(
"move",
src_path=path,
dst_path=target_path,
overwrite=False,
retarget=True,
)
if not move_response.success:
raise RuntimeError(f"move failed: {move_response.answer}")
return target_path
async def _save_session_messages(self, session_id: str, messages: list[Msg]) -> None:
if not session_id or not messages:
return
path = self._session_path(session_id)
self.logger.info(
f"[{self.name}] save session start session_id={session_id!r} messages={len(messages)} path={path}",
)
existing: list[Msg] = []
if path.exists():
async with aiofiles.open(path, encoding="utf-8") as f:
content = await f.read()
for line in content.splitlines():
line = line.strip()
if line:
try:
existing.append(Msg.model_validate_json(line))
except Exception:
pass
by_id: dict[str, Msg] = {}
for msg in existing:
by_id[msg.id] = msg
for msg in messages:
by_id[msg.id] = msg
merged = sorted(by_id.values(), key=lambda m: m.created_at)
can_append = 0 < len(existing) <= len(merged) and all(
merged[i].id == existing[i].id for i in range(len(existing))
)
path.parent.mkdir(parents=True, exist_ok=True)
if can_append:
new_msgs = merged[len(existing) :]
if new_msgs:
async with aiofiles.open(path, "a", encoding="utf-8") as f:
for msg in new_msgs:
await f.write(_sanitize_msg_for_save(msg).model_dump_json() + "\n")
self.logger.info(
f"[{self.name}] save session appended session_id={session_id!r} "
f"existing={len(existing)} appended={len(new_msgs)} total={len(merged)}",
)
else:
self.logger.info(
f"[{self.name}] save session unchanged session_id={session_id!r} "
f"existing={len(existing)} total={len(merged)}",
)
else:
async with aiofiles.open(path, "w", encoding="utf-8") as f:
for msg in merged:
await f.write(_sanitize_msg_for_save(msg).model_dump_json() + "\n")
self.logger.info(
f"[{self.name}] save session rewrote session_id={session_id!r} "
f"existing={len(existing)} total={len(merged)}",
)
@staticmethod
def _to_msg(item) -> Msg:
if isinstance(item, Msg):
return item
if isinstance(item, dict) and isinstance(item.get("content"), str):
item = {**item, "content": [{"type": "text", "text": item["content"]}]}
return Msg.model_validate(item)
# pylint: disable=too-many-return-statements
async def execute(self):
assert self.context is not None
raw_messages = self.context.get("messages") or []
session_id: str = self.context.get("session_id", "")
memory_hint: str = self.context.get("memory_hint", "")
tz = self.app_context.app_config.timezone if self.app_context is not None else None
current = now(tz)
messages: list[Msg] = [self._to_msg(item) for item in raw_messages]
self.logger.info(
f"[{self.name}] start session_id={session_id!r} raw_messages={len(raw_messages)} "
f"messages={len(messages)} hint={bool(memory_hint)}",
)
if session_id and (err := validate_session_id(session_id)):
self.context.response.success = False
self.context.response.answer = f"Error: {err}"
self.logger.warning(f"[{self.name}] invalid session_id={session_id!r} err={err}")
return
if not session_id:
self.context.response.success = False
self.context.response.answer = "Error: session_id is required"
self.logger.warning(f"[{self.name}] missing session_id")
return
await self._save_session_messages(session_id, messages)
if not messages:
self.context.response.success = True
self.context.response.answer = "Skipped: no messages"
self.context.response.metadata.update({"modified": False, "n_messages": 0})
self.logger.info(f"[{self.name}] Skipped: no messages session_id={session_id!r} modified=False")
return
day = current.strftime("%Y-%m-%d")
try:
note = await self._list_session_note(day, session_id)
except RuntimeError as exc:
self.context.response.success = False
self.context.response.answer = str(exc)
self.logger.info(f"[{self.name}] list failed session_id={session_id!r} answer={str(exc)!r}")
return
note_path = str(note["path"]) if note else ""
created = note is None
before_note_path = note_path
before_note_bytes = self._note_bytes(note_path) if note_path else None
self.logger.info(
f"[{self.name}] note lookup session_id={session_id!r} path={note_path!r} "
f"created={created} msgs={len(messages)} hint={bool(memory_hint)}",
)
template_key = "user_message_create" if created else "user_message_update"
user_message = self.prompt_format(
template_key,
today=day,
note=memory_hint or "(none)",
note_path=note_path,
session_id=session_id,
history=format_history(messages),
)
self.logger.info(f"[{self.name}] agent start path={note_path} template={template_key}")
result = await self.agent_wrapper.reply(
user_message,
system_prompt=self.prompt_format("system_prompt"),
job_tools=self.create_tools if created else self.update_tools,
)
self.logger.info(f"[{self.name}] agent done path={note_path} has_result={bool(result.get('result'))}")
if created:
try:
note = await self._list_session_note(day, session_id)
except RuntimeError as exc:
self.context.response.success = False
self.context.response.answer = str(exc)
self.context.response.metadata.update(
{"path": None, "created": created, "modified": False, "n_messages": len(messages)},
)
self.logger.info(f"[{self.name}] post-create list failed session_id={session_id!r} answer={str(exc)!r}")
return
if note is None:
self.context.response.success = True
self.context.response.answer = agent_reply_result_text(result)
self.context.response.metadata.update(
{"path": None, "created": False, "modified": False, "n_messages": len(messages)},
)
self.logger.info(f"[{self.name}] done without note session_id={session_id!r} modified=False")
return
note_path = str(note["path"])
else:
try:
await self._ensure_session_frontmatter(note_path, session_id)
note_path = await self._rename_from_frontmatter_name(note_path, day)
except RuntimeError as exc:
self.context.response.success = False
self.context.response.answer = str(exc)
self.context.response.metadata.update(
{
"path": note_path,
"created": created,
"modified": self._note_modified(before_note_path, before_note_bytes, note_path),
"n_messages": len(messages),
},
)
self.logger.info(f"[{self.name}] post-update failed path={note_path} answer={str(exc)!r}")
return
modified = self._note_modified(before_note_path, before_note_bytes, note_path)
daily_dir = self.config_value("daily_dir")
self.logger.info(f"[{self.name}] refresh index start date={day} daily_dir={daily_dir}")
index_payload = await refresh_day_index(self.file_store, day, daily_dir)
self.logger.info(f"[{self.name}] refresh index done path={note_path}")
source_conversation = self._session_link(session_id)
self.context.response.success = True
self.context.response.answer = agent_reply_result_text(result)
self.context.response.metadata.update(
{
"path": note_path,
"created": created,
"modified": modified,
"n_messages": len(messages),
"source_conversation": source_conversation,
"index": index_payload,
},
)
self.logger.info(f"[{self.name}] done {note_path} modified={modified}")