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