feat(memory): migrate MEMORY.md to global path & enhance ask-user prompts (#161)

* feat(prompt): enhance user interaction with multiple-choice and free-text questions

* refactor(paths): rename MEMORY_DIR to MEMORIES_DIR for consistency

* style(tests): format code for better readability in test cases

* feat(prompt): add validation for 'other' option in user prompt

* feat(prompt): refactor validation logic for user prompts and add skip option

* feat(style): refactor to use shared _PICKER_STYLE from interactive module
This commit is contained in:
Xi Zhang
2026-04-16 16:33:45 +02:00
committed by GitHub
parent 7f522cb4fe
commit 210e8864f6
13 changed files with 221 additions and 118 deletions
+5 -6
View File
@@ -257,7 +257,7 @@ def _get_default_backend():
workspace_dir = str(_paths_mod.WORKSPACE_ROOT)
set_active_workspace(workspace_dir)
memory_dir = str(_paths_mod.MEMORY_DIR)
memory_dir = str(_paths_mod.MEMORIES_DIR)
user_skills_dir = str(_paths_mod.USER_SKILLS_DIR)
global_skills_dir = str(_paths_mod.GLOBAL_SKILLS_DIR)
@@ -279,7 +279,7 @@ def _get_default_backend():
default=ws_backend,
routes={
"/skills/": sk_backend,
"/memory/": mem_backend,
"/memories/": mem_backend,
},
)
@@ -296,7 +296,7 @@ def _get_default_middleware():
cfg = _ensure_config()
model = _ensure_chat_model()
memory_dir = str(_paths_mod.MEMORY_DIR)
memory_dir = str(_paths_mod.MEMORIES_DIR)
mw = [
create_context_editing_middleware(model),
ContextOverflowMapperMiddleware(),
@@ -395,7 +395,7 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None, config
workspace_dir = str(_paths.WORKSPACE_ROOT)
# Read paths dynamically so runtime set_workspace_root() changes are picked up
_mem_dir = str(_paths.MEMORY_DIR)
_mem_dir = str(_paths.MEMORIES_DIR)
_usr_skills_dir = str(_paths.USER_SKILLS_DIR)
_global_skills_dir = str(_paths.GLOBAL_SKILLS_DIR)
@@ -412,7 +412,6 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None, config
global_dir=_global_skills_dir,
secondary_dir=SKILLS_DIR,
)
# Memory always uses SHARED directory (not per-session) for cross-session persistence
mem_backend = FilesystemBackend(
root_dir=_mem_dir,
virtual_mode=True,
@@ -421,7 +420,7 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None, config
default=ws_backend,
routes={
"/skills/": sk_backend,
"/memory/": mem_backend,
"/memories/": mem_backend,
},
)
+1 -1
View File
@@ -294,7 +294,7 @@ def cmd_interactive(
from .. import paths
memory_dir = str(paths.MEMORY_DIR)
memory_dir = str(paths.MEMORIES_DIR)
from ..config.settings import get_config_dir
+1 -12
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
from collections import Counter
import questionary
from prompt_toolkit.styles import Style as PtStyle
from questionary import Choice
from ..mcp.registry import (
@@ -22,17 +21,7 @@ from ..mcp.registry import (
install_mcp_servers,
)
from ..stream.display import console
_PICKER_STYLE = PtStyle.from_dict(
{
"questionmark": "#888888",
"question": "",
"pointer": "bold",
"highlighted": "bold",
"text": "#888888",
"answer": "bold",
}
)
from .interactive import _PICKER_STYLE
_INSTALLED_INDICATOR = ("fg:#4caf50", "\u2713 ")
+1 -12
View File
@@ -150,22 +150,11 @@ def _cmd_install_skills(args: str = "") -> None:
from collections import Counter
import questionary
from prompt_toolkit.styles import Style as PtStyle
from questionary import Choice
from ..paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
from ..tools.skills_manager import fetch_remote_skill_index, install_skill
_PICKER_STYLE = PtStyle.from_dict(
{
"questionmark": "#888888",
"question": "",
"pointer": "bold",
"highlighted": "bold",
"text": "#888888",
"answer": "bold",
}
)
from .interactive import _PICKER_STYLE
# Installed-item indicator style for disabled checkbox choices.
_INSTALLED_INDICATOR = ("fg:#4caf50", "✓ ")
@@ -49,7 +49,7 @@ class CurrentCommand(Command):
f"Workspace: {_shorten_path(ctx.workspace_dir)}",
style="dim",
)
memory_path = paths.MEMORY_DIR
memory_path = paths.MEMORIES_DIR
if memory_path:
from ...cli.agent import _shorten_path
+7 -7
View File
@@ -4,7 +4,7 @@ Automatically extracts and persists long-term memory (user profile, research
preferences, experiment conclusions) from conversations.
Two mechanisms:
1. **Injection** (every LLM call): Reads ``/memory/MEMORY.md`` and appends it
1. **Injection** (every LLM call): Reads ``/memories/MEMORY.md`` and appends it
to the system prompt so the agent always has context.
2. **Extraction** (threshold-triggered): When the conversation exceeds a
configurable message count, uses an LLM call to pull out structured facts
@@ -17,7 +17,7 @@ from EvoScientist.middleware import EvoMemoryMiddleware
middleware = EvoMemoryMiddleware(
backend=my_backend, # or backend factory
memory_path="/memory/MEMORY.md",
memory_path="/memories/MEMORY.md",
extraction_model=chat_model,
trigger=("messages", 20),
)
@@ -168,7 +168,7 @@ Use this to personalize your responses and avoid re-asking known information.
- An experiment completes with notable conclusions
**How to update memory:**
- If `/memory/MEMORY.md` does not exist yet, use `write_file` to create it
- If `/memories/MEMORY.md` does not exist yet, use `write_file` to create it
- If it already exists, use `edit_file` to update specific sections
- Use this markdown structure:
@@ -446,7 +446,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
Args:
backend: Backend instance or factory for reading/writing memory files.
memory_path: Virtual path to MEMORY.md (default ``/memory/MEMORY.md``).
memory_path: Virtual path to MEMORY.md (default ``/memories/MEMORY.md``).
extraction_model: Chat model used for extraction (can be a cheap/fast
model like ``claude-haiku``). If ``None``, automatic extraction is
disabled and only prompt injection + manual ``edit_file`` works.
@@ -461,7 +461,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
self,
*,
backend: BACKEND_TYPES,
memory_path: str = "/memory/MEMORY.md",
memory_path: str = "/memories/MEMORY.md",
extraction_model: BaseChatModel | None = None,
trigger: tuple[str, int] = ("messages", 20),
) -> None:
@@ -687,7 +687,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
logger.debug("Failed to load memory during modify_request: %s", e)
# Use placeholder when memory file doesn't exist yet
if not memory_content:
memory_content = "(No memory saved yet. Create `/memory/MEMORY.md` when you learn important information.)"
memory_content = "(No memory saved yet. Create `/memories/MEMORY.md` when you learn important information.)"
from deepagents.middleware._utils import append_to_system_message
@@ -802,7 +802,7 @@ def create_memory_middleware(
"""
from deepagents.backends import FilesystemBackend
from ..paths import MEMORY_DIR as _DEFAULT_MEMORY_DIR
from ..paths import MEMORIES_DIR as _DEFAULT_MEMORY_DIR
if memory_dir is None:
memory_dir = str(_DEFAULT_MEMORY_DIR)
+30 -4
View File
@@ -22,7 +22,6 @@ def _env_path(key: str) -> Path | None:
WORKSPACE_ROOT = _env_path("EVOSCIENTIST_WORKSPACE_DIR") or Path.cwd()
RUNS_DIR = _env_path("EVOSCIENTIST_RUNS_DIR") or (WORKSPACE_ROOT / "runs")
MEMORY_DIR = _env_path("EVOSCIENTIST_MEMORY_DIR") or (WORKSPACE_ROOT / "memory")
USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "skills")
MEDIA_DIR = _env_path("EVOSCIENTIST_MEDIA_DIR") or (WORKSPACE_ROOT / "media")
@@ -33,9 +32,27 @@ def _global_skills_dir() -> Path:
return base / "evoscientist" / "skills"
def _global_memories_dir() -> Path:
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else Path.home() / ".config"
return base / "evoscientist" / "memories"
# Global skills: shared across all workspaces (~/.config/evoscientist/skills/)
GLOBAL_SKILLS_DIR: Path = _global_skills_dir()
# Global memories: shared across all workspaces (~/.config/evoscientist/memories/)
GLOBAL_MEMORIES_DIR: Path = _global_memories_dir()
# Memories dir: global by default, overridable via env var.
# Supports both new (EVOSCIENTIST_MEMORIES_DIR) and old (EVOSCIENTIST_MEMORY_DIR) env vars.
MEMORIES_DIR: Path = (
_env_path("EVOSCIENTIST_MEMORIES_DIR")
or _env_path("EVOSCIENTIST_MEMORY_DIR")
or GLOBAL_MEMORIES_DIR
)
MEMORY_DIR = MEMORIES_DIR # backward compat alias
def set_workspace_root(path: str | Path) -> None:
"""Update workspace root and re-derive dependent directories.
@@ -43,10 +60,14 @@ def set_workspace_root(path: str | Path) -> None:
Directories with an explicit environment-variable override keep their
env-var value; all others are re-derived from the new root.
Also resets ``_active_workspace`` to the new root as a safe default.
Note: MEMORIES_DIR is global (not workspace-scoped) but env var overrides
are re-evaluated here to support late-set environment variables.
"""
global \
WORKSPACE_ROOT, \
RUNS_DIR, \
MEMORIES_DIR, \
MEMORY_DIR, \
USER_SKILLS_DIR, \
MEDIA_DIR, \
@@ -54,7 +75,12 @@ def set_workspace_root(path: str | Path) -> None:
WORKSPACE_ROOT = Path(path).resolve()
_active_workspace = WORKSPACE_ROOT
RUNS_DIR = _env_path("EVOSCIENTIST_RUNS_DIR") or (WORKSPACE_ROOT / "runs")
MEMORY_DIR = _env_path("EVOSCIENTIST_MEMORY_DIR") or (WORKSPACE_ROOT / "memory")
MEMORIES_DIR = (
_env_path("EVOSCIENTIST_MEMORIES_DIR")
or _env_path("EVOSCIENTIST_MEMORY_DIR")
or GLOBAL_MEMORIES_DIR
)
MEMORY_DIR = MEMORIES_DIR
USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (
WORKSPACE_ROOT / "skills"
)
@@ -64,13 +90,13 @@ def set_workspace_root(path: str | Path) -> None:
def ensure_dirs() -> None:
"""Create runtime subdirectories if they do not exist.
Only memory is created eagerly — skills directories are created on demand
Only memories is created eagerly — skills directories are created on demand
by install_skill() when the user first installs a skill.
Does NOT create the workspace root itself — it should already exist
(either the user's cwd or a directory they specified).
"""
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
MEMORIES_DIR.mkdir(parents=True, exist_ok=True)
def default_workspace_dir() -> Path:
+61 -35
View File
@@ -987,16 +987,20 @@ def _get_event_loop() -> asyncio.AbstractEventLoop:
def _resolve_ask_user_prompt(ask_user_data: dict) -> dict:
"""Interactive console Q&A for ask_user events.
Presents questions via ``prompt_toolkit.prompt()`` (not ``input()``)
for proper CJK IME support and styled prompts without cursor drift.
Presents multiple-choice questions with arrow-key navigation via
``questionary.select()`` and free-text questions via
``questionary.text()`` with required-field validation. Matches the
questionary style used throughout the rest of the CLI.
"""
from prompt_toolkit import prompt as pt_prompt # type: ignore[import-untyped]
from prompt_toolkit.formatted_text import HTML # type: ignore[import-untyped]
import questionary # type: ignore[import-untyped]
from ..cli.interactive import _PICKER_STYLE
questions = ask_user_data.get("questions", [])
if not questions:
return {"answers": [], "status": "answered"}
total = len(questions)
console.print()
console.print(
Panel(
@@ -1013,43 +1017,65 @@ def _resolve_ask_user_prompt(ask_user_data: dict) -> dict:
q_text = q.get("question", "")
q_type = q.get("type", "text")
required = q.get("required", True)
tag = " [dim](optional)[/dim]" if not required else ""
console.print(f" [bold]{i + 1}. {q_text}[/bold]{tag}")
optional_suffix = " (optional)" if not required else ""
prompt_text = f"({i + 1}/{total}) {q_text}{optional_suffix}"
def _make_validator(is_required: bool):
def _validate(v: str) -> bool | str:
if is_required and not v.strip():
return "This field is required."
return True
return _validate
if q_type == "multiple_choice":
choices = q.get("choices", [])
for j, choice in enumerate(choices):
label = choice.get("value", str(choice))
letter = chr(ord("A") + j)
console.print(Text(f" {letter}. {label}", style="dim"))
other_letter = chr(ord("A") + len(choices))
console.print(
Text(f" {other_letter}. Other (type your answer)", style="dim")
)
choice_labels = [c.get("value", str(c)) for c in choices]
skip_label = "Skip"
if not required:
choice_labels.append(skip_label)
other_label = "Other (type your answer)"
choice_labels.append(other_label)
selected = questionary.select(
prompt_text,
choices=choice_labels,
style=_PICKER_STYLE,
).ask()
if selected is None: # Ctrl+C
raise KeyboardInterrupt
if selected == skip_label:
answers.append("")
console.print()
continue
if selected == other_label:
selected = questionary.text(
"Your answer:",
validate=_make_validator(required),
style=_PICKER_STYLE,
).ask()
if selected is None:
raise KeyboardInterrupt
answers.append(selected)
letters = "/".join(chr(ord("A") + k) for k in range(len(choices) + 1))
raw = pt_prompt(
HTML(f" <b><style fg='#1565c0'>Choice [{letters}]:</style></b> ")
).strip()
if raw.upper() == other_letter:
raw = pt_prompt(
HTML(" <b><style fg='#42a5f5'>&gt; Your answer:</style></b> ")
).strip()
answers.append(raw)
elif len(raw) == 1 and raw.upper().isalpha():
idx = ord(raw.upper()) - ord("A")
if 0 <= idx < len(choices):
answers.append(choices[idx].get("value", raw))
else:
answers.append(raw)
else:
answers.append(raw)
else:
raw = pt_prompt(
HTML(" <b><style fg='#42a5f5'>&gt; Answer:</style></b> ")
).strip()
answers.append(raw)
answer = questionary.text(
prompt_text,
validate=_make_validator(required),
style=_PICKER_STYLE,
).ask()
if answer is None: # Ctrl+C
raise KeyboardInterrupt
answers.append(answer)
console.print()
except (EOFError, KeyboardInterrupt):
console.print("[dim] Cancelled.[/dim]")
return {"status": "cancelled"}
+3 -3
View File
@@ -114,9 +114,9 @@ def _tool_path_arg(args: dict | None) -> str:
def _is_memory_path(path: str) -> bool:
"""Return True when a virtual path targets the shared memory directory."""
"""Return True when a virtual path targets the global memories directory."""
normalized = (path or "").strip()
return normalized == "/memory" or normalized.startswith("/memory/")
return normalized == "/memories" or normalized.startswith("/memories/")
def format_tool_compact(name: str, args: dict | None) -> str:
@@ -250,7 +250,7 @@ def format_tool_compact_with_result(
if name_lower in ("write_file", "edit_file"):
if (
"/memory/" in result_content
"/memories/" in result_content
or "/MEMORY.md" in result_content
or "MEMORY.md" in result_content
):