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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user