feat(my-deepagent): v0.3 PR #3 — auto-memory (project-scoped MEMORY.md + /remember/forget)

Claude Code의 auto-memory + `/remember`/`/forget` 슬래시 등가.  사전 검증
false-positive 였던 deepagents `memory=` kwarg 동작을 확정 (실제로
`MemoryMiddleware` 가 sources 리스트를 매 ainvoke 마다 backend 로 download
해서 system prompt 에 `<agent_memory>` 블록 으로 inject).

핵심 동작:
- 세션 시작 시 `<config.data_dir>/projects/<project_key>/memory/` 디렉터리 부트스트랩
  + `MEMORY.md` (index) 자동 생성 (idempotent).  `project_key` =
  `sha256(realpath(repo_path))[:16]` 라서 같은 repo 는 세션 간 동일 memory.
- 매 agent 재빌드 시 `list_memory_paths(memory_dir)`로 현재 `*.md` 목록을
  다시 읽어 deepagents `memory=` kwarg 로 전달.  index 파일이 항상 첫 번째 →
  ToC 역할.
- `/remember <text>`: `<slug>.md` 파일 생성 + index 에 pointer 한 줄 append +
  `clear_agent_cache()` 로 다음 턴에 새 파일 반영.
- `/forget <slug>`: 파일 삭제 + index 라인 prune + cache flush.
- `/memory`: 현재 디렉터리의 entry 목록 표시.

데이터·라이브러리:
- `memory.py` (신규): `project_memory_dir` / `ensure_memory_initialized` /
  `list_memory_paths` / `add_memory_entry` (슬러그 충돌 시 `-2`/`-3` suffix) /
  `remove_memory_entry` (index 자체는 삭제 거부) / `memory_entries_summary` /
  `_slugify`.
- `session.py`: `build_agent(..., memory_paths_override=...)` 신규 kwarg.
  `persona.memory_files`와 합쳐 deepagents `memory=` 로 전달 (empty 이면
  kwarg 자체 생략).  `_resolve_memory_paths` 헬퍼 추출 (C901 회피).
- `cli/interactive.py`: `InteractiveSession` 시그니처에 `project_key: str` 추가.
  `_register_memory_slash` 신규.

테스트 (`tests/integration/test_memory.py`, 22 케이스):
- Bootstrap idempotency
- add/remove 정상/실패 (slug 충돌, 없는 항목, index 보호, 빈 입력 거부)
- list 순서 (index 우선), 누락된 디렉터리 처리
- project_key 격리, empty key 거부
- `_slugify` 영문/유니코드 fallback/max_len
- **integration**: `build_agent(..., memory_paths_override=...)`가 실제로
  `create_deep_agent(memory=...)` 까지 전달되는지 monkeypatch 로 검증.
  Plan §사전검증 #5 false-positive 해소.

게이트:
- ruff check / format --check / mypy: PASS
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
  --ignore=tests/integration/test_openrouter_smoke.py: 633 passed (22 신규 포함)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-17 20:36:50 +09:00
parent f78b26dc69
commit 15b33e22fe
5 changed files with 647 additions and 3 deletions

View File

@@ -36,6 +36,14 @@ from ..budget import make_budget_tracker_from_config
from ..compaction import compact_session, should_compact
from ..config import Config, load_config
from ..governance import require_consent
from ..memory import (
add_memory_entry,
ensure_memory_initialized,
list_memory_paths,
memory_entries_summary,
project_memory_dir,
remove_memory_entry,
)
from ..middleware.audit import AuditToolMiddleware
from ..middleware.cost import CostMiddleware
from ..monitoring.pricing import ModelPrice, PricingCache
@@ -129,6 +137,7 @@ class InteractiveSession:
repo_root: Path,
session_id: UUID,
saver: Any,
project_key: str,
) -> None:
self.config = config
self.personas = personas
@@ -137,12 +146,18 @@ class InteractiveSession:
self.repo_root = repo_root
self.session_id = session_id
self.saver = saver
self.project_key = project_key
self._model_override: str | None = None
self._persona = self._default_persona()
self._agent: Any | None = None
# thread_suffix bumps on /model and compaction; LangGraph thread_id =
# f"{session_id}:{suffix}" so model switches start fresh deepagents state.
self._thread_suffix: int = 0
# v0.3 PR #3: per-project memory dir bootstrap. Idempotent so resumes
# are cheap. Path is determined entirely by config + project_key —
# the same repo across sessions hits the same memory.
self.memory_dir: Path = project_memory_dir(config, project_key)
ensure_memory_initialized(self.memory_dir)
@property
def thread_id(self) -> str:
@@ -205,6 +220,9 @@ class InteractiveSession:
interactive_session_id=self.session_id,
file_recorder=make_audit_recorder(self.config.state_dir),
)
# Re-glob memory paths every time the agent is rebuilt — `/remember` and
# `/forget` call `clear_agent_cache()` so this picks up new/removed files.
memory_paths = list_memory_paths(self.memory_dir)
self._agent = build_agent(
self._persona,
self.config,
@@ -212,6 +230,7 @@ class InteractiveSession:
middleware=[cost_mw, audit_mw],
model_override=self._model_override,
checkpointer=self.saver,
memory_paths_override=memory_paths,
)
return self._agent
@@ -482,11 +501,62 @@ def _register_compaction_slash(reg: SlashRegistry, sess: InteractiveSession) ->
reg.register("compact", _compact, help="manually compact the conversation history")
def _register_memory_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
"""Register /remember, /forget, /memory slash handlers (v0.3 PR #3)."""
async def _remember(cmd: SlashParsed) -> bool:
# /remember <text> — strip the leading "remember" word from raw to
# preserve original whitespace inside the entry body.
text = cmd.raw[len("remember") :].strip() if cmd.raw.lower().startswith("remember") else ""
if not text:
_CONSOLE.print(
"[yellow]usage:[/] /remember <text> — saves a memory file for this project."
)
return False
try:
path = add_memory_entry(sess.memory_dir, text)
except ValueError as e:
_CONSOLE.print(f"[red]{e}[/]")
return False
# Force rebuild on next turn so MemoryMiddleware picks up the new file.
sess.clear_agent_cache()
_CONSOLE.print(f"[green]remembered →[/] {path.name} (new thread, memory reloaded)")
return False
async def _forget(cmd: SlashParsed) -> bool:
if not cmd.args:
_CONSOLE.print("[yellow]usage:[/] /forget <slug> — remove a memory file.")
return False
slug = cmd.args[0]
removed = remove_memory_entry(sess.memory_dir, slug)
if not removed:
_CONSOLE.print(f"[yellow]no memory file found for:[/] {slug}")
return False
sess.clear_agent_cache()
_CONSOLE.print(f"[green]forgotten →[/] {slug} (new thread, memory reloaded)")
return False
async def _memory(_: SlashParsed) -> bool:
entries = memory_entries_summary(sess.memory_dir)
_CONSOLE.print(f"[bold]project memory[/] ({sess.memory_dir})")
if not entries:
_CONSOLE.print(" [dim](no entries — try /remember <text>)[/]")
return False
for name, size in entries:
_CONSOLE.print(f" - {name} [dim]({size} chars)[/]")
return False
reg.register("remember", _remember, help="save a memory: /remember <text>")
reg.register("forget", _forget, help="remove a memory: /forget <slug>")
reg.register("memory", _memory, help="list memory entries for this project")
def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
_register_navigation_slash(reg, sess)
_register_persona_slash(reg, sess)
_register_telemetry_slash(reg)
_register_compaction_slash(reg, sess)
_register_memory_slash(reg, sess)
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
@@ -667,10 +737,25 @@ async def _interactive_loop_async(
session_id = uuid4()
creating = True
# Per-project memory uses a sha256-hash of the realpath — same as the
# session row's project_key column, computed once here and reused.
from ..hash import sha256
project_key = sha256(str(Path.cwd().resolve()))[:16]
try:
async with get_checkpointer_ctx(config.database_url) as saver:
# Resolve initial persona (may be overridden below).
sess = InteractiveSession(config, personas, db, pricing, Path.cwd(), session_id, saver)
sess = InteractiveSession(
config,
personas,
db,
pricing,
Path.cwd(),
session_id,
saver,
project_key,
)
if persona_override:
try:
sess.set_persona(persona_override)

View File

@@ -0,0 +1,187 @@
"""Auto-memory (v0.3 PR #3) — project-scoped persistent context.
Layout::
<config.data_dir>/projects/<project_key>/memory/
MEMORY.md # index — one line per entry: "- [Title](file.md) — hook"
<slug>.md # individual memory entries (with optional frontmatter)
The deepagents `MemoryMiddleware` reads every path we pass via the `memory=`
kwarg of `create_deep_agent` and injects them (concatenated) into the system
prompt under an `<agent_memory>` block. The middleware re-reads files on
every turn, so updates take effect on the next user message — no agent
rebuild required.
`/remember <text>` appends a new entry file and updates the index. `/forget
<slug>` deletes the entry file and prunes the index. Both are project-scoped
(via `project_key`) so different repos keep separate memory.
"""
from __future__ import annotations
import re
from datetime import UTC, datetime
from pathlib import Path
from .config import Config
#: Filename of the index file inside each project memory dir.
INDEX_FILENAME = "MEMORY.md"
#: Slug character set — kept conservative for filesystem portability.
_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
#: Initial index body when bootstrapping a fresh memory directory.
_INITIAL_INDEX = """# Auto-memory
This file is an index of stored memories for this project. Each entry below
points to a sibling `*.md` file. Entries are auto-managed by `/remember` and
`/forget` slash commands — edit by hand if you need finer control.
## Entries
"""
def project_memory_dir(config: Config, project_key: str) -> Path:
"""Return the absolute directory path for this project's memory."""
if not project_key:
raise ValueError("project_key must be non-empty")
return Path(config.data_dir) / "projects" / project_key / "memory"
def ensure_memory_initialized(memory_dir: Path) -> Path:
"""Create the memory directory + initial MEMORY.md if missing.
Idempotent — repeated calls are no-ops once initialised. Returns the
path to the index file so callers can pass it to deepagents.
"""
memory_dir.mkdir(parents=True, exist_ok=True)
index_path = memory_dir / INDEX_FILENAME
if not index_path.exists():
index_path.write_text(_INITIAL_INDEX, encoding="utf-8")
return index_path
def list_memory_paths(memory_dir: Path) -> list[str]:
"""Return absolute paths of all `*.md` files in the directory, sorted.
The index file appears first (deepagents concatenates in order, so the
index serves as a table of contents inside the system prompt). Returns
an empty list if the directory does not exist.
"""
if not memory_dir.is_dir():
return []
paths = sorted(p for p in memory_dir.glob("*.md") if p.is_file())
# Move the index to the front if present.
index_path = memory_dir / INDEX_FILENAME
if index_path in paths:
paths.remove(index_path)
paths.insert(0, index_path)
return [str(p.resolve()) for p in paths]
def _slugify(text: str, *, max_len: int = 40) -> str:
"""Lowercase + replace non-[a-z0-9_-] with `-`, trim to ``max_len``.
Empty result → fallback "entry". Used by `/remember` for auto-named
files when the user does not pass an explicit slug.
"""
slug = _SLUG_RE.sub("-", text.lower().strip()).strip("-")
slug = slug[:max_len].rstrip("-")
return slug or "entry"
def _now_iso() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def add_memory_entry(
memory_dir: Path,
content: str,
*,
name: str | None = None,
) -> Path:
"""Write a new memory file + append pointer to the index.
- ``name`` (optional): explicit slug. If omitted, derived from the first
line of ``content`` via :func:`_slugify`.
- File names collide → ``-2``, ``-3``, … suffix is appended until unique.
Returns the absolute path to the newly written file. Raises
``ValueError`` for empty content.
"""
if not content or not content.strip():
raise ValueError("memory content must be non-empty")
ensure_memory_initialized(memory_dir)
first_line = content.strip().splitlines()[0]
slug_base = _slugify(name or first_line)
candidate = memory_dir / f"{slug_base}.md"
n = 2
while candidate.exists():
candidate = memory_dir / f"{slug_base}-{n}.md"
n += 1
# File body: short frontmatter + content. The frontmatter is informational
# for human readers; the deepagents middleware does not parse it.
body = f"---\nslug: {candidate.stem}\ncreated: {_now_iso()}\n---\n\n{content.strip()}\n"
candidate.write_text(body, encoding="utf-8")
# Append a one-line pointer to the index — first line of content is the
# title, truncated to keep the index scannable.
title = first_line.strip().lstrip("# ").strip()[:80] or candidate.stem
pointer = f"- [{title}]({candidate.name}) — {_now_iso()}\n"
index_path = memory_dir / INDEX_FILENAME
with index_path.open("a", encoding="utf-8") as f:
f.write(pointer)
return candidate
def remove_memory_entry(memory_dir: Path, slug_or_filename: str) -> bool:
"""Delete a memory entry by slug or filename. Returns True if removed.
Matching is exact against ``<slug>.md`` or ``<slug>`` (the extension is
optional). Also prunes any matching pointer line from the index.
"""
if not slug_or_filename:
return False
target_name = slug_or_filename if slug_or_filename.endswith(".md") else f"{slug_or_filename}.md"
target_path = memory_dir / target_name
if not target_path.is_file():
return False
if target_path.name == INDEX_FILENAME:
# Refuse to delete the index itself — it's regenerated on next session.
return False
target_path.unlink()
# Prune pointer lines that mention this filename.
index_path = memory_dir / INDEX_FILENAME
if index_path.is_file():
original = index_path.read_text(encoding="utf-8")
pruned_lines = [
line for line in original.splitlines(keepends=True) if f"({target_name})" not in line
]
index_path.write_text("".join(pruned_lines), encoding="utf-8")
return True
def memory_entries_summary(memory_dir: Path) -> list[tuple[str, int]]:
"""Return ``[(filename, char_count), ...]`` excluding the index, sorted by name.
Used by `/memory` slash to show the user what's currently stored.
"""
if not memory_dir.is_dir():
return []
out: list[tuple[str, int]] = []
for p in sorted(memory_dir.glob("*.md")):
if p.name == INDEX_FILENAME:
continue
try:
out.append((p.name, len(p.read_text(encoding="utf-8"))))
except OSError:
continue
return out

View File

@@ -205,6 +205,7 @@ def build_agent(
run_id: UUID | None = None,
phase_key: str | None = None,
model_override: str | None = None,
memory_paths_override: list[str] | None = None,
) -> Any:
"""Construct a deepagents CompiledStateGraph for the given persona.
@@ -269,7 +270,23 @@ def build_agent(
kwargs["checkpointer"] = checkpointer
if persona.skills:
kwargs["skills"] = list(persona.skills)
if persona.memory_files:
kwargs["memory"] = list(persona.memory_files)
memory_paths = _resolve_memory_paths(persona, memory_paths_override)
if memory_paths:
kwargs["memory"] = memory_paths
return create_deep_agent(**kwargs)
def _resolve_memory_paths(persona: Persona, override: list[str] | None) -> list[str]:
"""Combine persona-defined memory files with caller-supplied per-session paths.
v0.3 PR #3 — InteractiveSession passes project-scoped MEMORY.md + entries via
``memory_paths_override``. Order matters: persona files first (static role
context), then session files (dynamic per-project remembered facts) — later
sources override earlier ones at the same source path
(``deepagents.MemoryMiddleware``).
"""
combined: list[str] = list(persona.memory_files)
if override:
combined.extend(override)
return combined