diff --git a/EvoScientist/backends.py b/EvoScientist/backends.py index 94dca90..00edbf2 100644 --- a/EvoScientist/backends.py +++ b/EvoScientist/backends.py @@ -291,7 +291,7 @@ class MergedSkillsBackend(BackendProtocol): Priority (high → low): 1. primary — workspace/skills/ (project-local, writable) - 2. global — ~/.config/evoscientist/skills/ (user global, read-only) + 2. global — ~/.evoscientist/skills/ (user global, read-only) 3. secondary — EvoScientist/skills/ (built-in, PyPI, read-only) Higher-priority skills override lower-priority skills with the same name. diff --git a/EvoScientist/cli/history_suggester.py b/EvoScientist/cli/history_suggester.py index 1d3600f..66782e9 100644 --- a/EvoScientist/cli/history_suggester.py +++ b/EvoScientist/cli/history_suggester.py @@ -1,7 +1,7 @@ """History-based auto-suggest for Textual TUI Input widget. Reads prompt_toolkit FileHistory format so Rich CLI and TUI share the same -history file at ~/.config/evoscientist/history. +history file at ~/.evoscientist/history. """ from __future__ import annotations diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index f8cae4e..90d5a79 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -296,11 +296,8 @@ def cmd_interactive( memory_dir = str(paths.MEMORIES_DIR) - from ..config.settings import get_config_dir - - config_dir = get_config_dir() - config_dir.mkdir(parents=True, exist_ok=True) - history_file = str(config_dir / "history") + paths.DATA_DIR.mkdir(parents=True, exist_ok=True) + history_file = str(paths.DATA_DIR / "history") # Key bindings: Enter submits, Alt+Enter (Option+Enter) inserts newline _kb = KeyBindings() diff --git a/EvoScientist/cli/skills_cmd.py b/EvoScientist/cli/skills_cmd.py index 9f13602..695504b 100644 --- a/EvoScientist/cli/skills_cmd.py +++ b/EvoScientist/cli/skills_cmd.py @@ -58,7 +58,7 @@ def _cmd_list_skills() -> None: def _cmd_install_skill(args: str) -> None: """Install a skill from local path or GitHub URL. - By default, installs to the global skills directory (~/.config/evoscientist/skills/). + By default, installs to the global skills directory (~/.evoscientist/skills/). Append --local to install to the current workspace instead. Usage: /install-skill [--local] diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 582a94c..fb8968b 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -22,7 +22,7 @@ import EvoScientist.cli.channel as _ch_mod from ..commands import CommandContext from ..commands import manager as cmd_manager -from ..config.settings import get_config_dir +from ..paths import DATA_DIR from ..sessions import ( find_similar_threads, generate_thread_id, @@ -353,7 +353,7 @@ def run_textual_interactive( self._picker_future: asyncio.Future | None = None self._browser_future: asyncio.Future | None = None self._mcp_browser_future: asyncio.Future | None = None - self._history_suggester = HistorySuggester(get_config_dir() / "history") + self._history_suggester = HistorySuggester(DATA_DIR / "history") self._history_index: int = -1 # -1 = not browsing history self._history_saved_input: str = "" # saved current input before browsing self._background_tasks: set[asyncio.Task] = set() diff --git a/EvoScientist/paths.py b/EvoScientist/paths.py index 82d3eab..c813f77 100644 --- a/EvoScientist/paths.py +++ b/EvoScientist/paths.py @@ -2,10 +2,14 @@ from __future__ import annotations +import logging import os +import shutil from datetime import datetime from pathlib import Path +logger = logging.getLogger(__name__) + def _expand(path: str) -> Path: return Path(path).expanduser() @@ -26,22 +30,32 @@ USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "ski MEDIA_DIR = _env_path("EVOSCIENTIST_MEDIA_DIR") or (WORKSPACE_ROOT / "media") +def _global_data_dir() -> Path: + """Global application data directory (~/.evoscientist/ by default). + + This is the base for sessions.db, skills/, memories/, history — things + that are NOT configuration but application state. Config files (config.yaml, + mcp.yaml) continue to live in XDG_CONFIG_HOME. + """ + return Path.home() / ".evoscientist" + + +# Global data dir: ~/.evoscientist/ by default, overridable via env var. +DATA_DIR: Path = _env_path("EVOSCIENTIST_DATA_DIR") or _global_data_dir() + + def _global_skills_dir() -> Path: - xdg = os.environ.get("XDG_CONFIG_HOME") - base = Path(xdg) if xdg else Path.home() / ".config" - return base / "evoscientist" / "skills" + return DATA_DIR / "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" + return DATA_DIR / "memories" -# Global skills: shared across all workspaces (~/.config/evoscientist/skills/) +# Global skills: shared across all workspaces (~/.evoscientist/skills/) GLOBAL_SKILLS_DIR: Path = _global_skills_dir() -# Global memories: shared across all workspaces (~/.config/evoscientist/memories/) +# Global memories: shared across all workspaces (~/.evoscientist/memories/) GLOBAL_MEMORIES_DIR: Path = _global_memories_dir() # Memories dir: global by default, overridable via env var. @@ -54,6 +68,80 @@ MEMORIES_DIR: Path = ( MEMORY_DIR = MEMORIES_DIR # backward compat alias +# DEPRECATED(0.1.0): remove this migration helper and its call site below. +def migrate_legacy_sessions_db() -> None: + """One-time migration: copy sessions.db (and its WAL/SHM siblings) from + ~/.config/evoscientist/ to ~/.evoscientist/. + + Scope is intentionally narrow — only the SQLite trio, because users can't + easily move those by hand. User-facing files (skills/, memories/, history) + are migrated via an agent prompt documented in the release notes. + + Idempotent via ``.migrated`` marker file. The marker is not written when + a copy fails, so transient I/O errors don't permanently block retry. + """ + marker = DATA_DIR / ".migrated" + if marker.exists(): + return + + try: + DATA_DIR.mkdir(parents=True, exist_ok=True) + except OSError: + logger.debug("Could not create %s; skipping legacy migration.", DATA_DIR) + return + + # Resolve legacy source via XDG_CONFIG_HOME (matches config.settings.get_config_dir). + # Inlined here to avoid importing config.settings at paths load time. + xdg = os.environ.get("XDG_CONFIG_HOME") + legacy = ( + (Path(xdg) / "evoscientist") + if xdg + else (Path.home() / ".config" / "evoscientist") + ) + if not legacy.exists(): + marker.touch() + return + + migrated: list[str] = [] + failed: list[str] = [] + for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"): + src = legacy / name + dst = DATA_DIR / name + if not src.exists(): + continue + if dst.exists(): + continue # already migrated + try: + shutil.copy2(src, dst) + migrated.append(name) + except OSError as e: + logger.warning("Failed to migrate %s: %s", src, e) + failed.append(name) + + if migrated: + logger.info( + "Migrated legacy session DB from %s to %s: %s. " + "Legacy files are kept as backup; this auto-migration will be " + "removed in EvoScientist 0.1.0.", + legacy, + DATA_DIR, + ", ".join(migrated), + ) + + # Only write the marker when there were no failures — preserves retry + # on transient I/O errors. + if not failed: + marker.touch() + + +# DEPRECATED(0.1.0): remove this call together with migrate_legacy_sessions_db(). +try: + migrate_legacy_sessions_db() +except Exception: + # Never block startup on migration failures + logger.exception("Legacy session DB migration failed; continuing without it.") + + def set_workspace_root(path: str | Path) -> None: """Update workspace root and re-derive dependent directories. @@ -90,12 +178,14 @@ def set_workspace_root(path: str | Path) -> None: def ensure_dirs() -> None: """Create runtime subdirectories if they do not exist. - Only memories is created eagerly — skills directories are created on demand - by install_skill() when the user first installs a skill. + Creates DATA_DIR (and MEMORIES_DIR as its subdir). 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). """ + DATA_DIR.mkdir(parents=True, exist_ok=True) MEMORIES_DIR.mkdir(parents=True, exist_ok=True) diff --git a/EvoScientist/sessions.py b/EvoScientist/sessions.py index 495e029..54565ca 100644 --- a/EvoScientist/sessions.py +++ b/EvoScientist/sessions.py @@ -64,13 +64,13 @@ def _to_short_path(path: str) -> str: def get_db_path() -> Path: """Return the sessions database path, creating parents. - Reuses ``get_config_dir()`` for XDG_CONFIG_HOME support, then applies + Uses ``paths.DATA_DIR`` (~/.evoscientist/ by default), then applies a best-effort Windows 8.3 short-path conversion on the *directory* (which exists after ``mkdir``) so sqlite3 can handle non-ASCII paths. """ - from .config.settings import get_config_dir + from .paths import DATA_DIR - db_dir = get_config_dir() + db_dir = DATA_DIR db_dir.mkdir(parents=True, exist_ok=True) return Path(_to_short_path(str(db_dir))) / "sessions.db" diff --git a/EvoScientist/tools/skills_manager.py b/EvoScientist/tools/skills_manager.py index 041b7a7..368869b 100644 --- a/EvoScientist/tools/skills_manager.py +++ b/EvoScientist/tools/skills_manager.py @@ -1,7 +1,7 @@ """Skill installation and management for EvoScientist. This module provides functions for installing, listing, and uninstalling user skills. -Skills are installed to GLOBAL_SKILLS_DIR by default (~/.config/evoscientist/skills/). +Skills are installed to GLOBAL_SKILLS_DIR by default (~/.evoscientist/skills/). Pass global_install=False to install to USER_SKILLS_DIR (/skills/) instead. Supported installation sources: @@ -278,7 +278,7 @@ def install_skill( source: Local directory path or GitHub URL/shorthand. dest_dir: Explicit destination directory (overrides global_install). global_install: If True (default), install to GLOBAL_SKILLS_DIR - (~/.config/evoscientist/skills/). If False, install to the + (~/.evoscientist/skills/). If False, install to the workspace-local USER_SKILLS_DIR. Returns: @@ -501,7 +501,7 @@ def list_skills(include_system: bool = False) -> list[SkillInfo]: # Tier 1: workspace-local skills (always highest priority, no dedup needed) _add_tier(Path(paths.USER_SKILLS_DIR), source="workspace", check_seen=False) - # Tier 2: global skills (~/.config/evoscientist/skills/) + # Tier 2: global skills (~/.evoscientist/skills/) _add_tier(Path(paths.GLOBAL_SKILLS_DIR), source="global") # Tier 3: built-in skills (optional) diff --git a/EvoScientist/update_check.py b/EvoScientist/update_check.py index 771b4c2..41200f1 100644 --- a/EvoScientist/update_check.py +++ b/EvoScientist/update_check.py @@ -10,12 +10,13 @@ from __future__ import annotations import json import logging import time -from pathlib import Path + +from .config.settings import get_config_dir logger = logging.getLogger(__name__) PYPI_URL = "https://pypi.org/pypi/EvoScientist/json" -CACHE_DIR = Path("~/.config/evoscientist").expanduser() +CACHE_DIR = get_config_dir() CACHE_FILE = CACHE_DIR / "latest_version.json" CACHE_TTL = 86_400 # 24 hours diff --git a/tests/test_paths.py b/tests/test_paths.py index 29c0391..1b9d8c4 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -14,16 +14,22 @@ def _restore_paths(): orig = { "WORKSPACE_ROOT": paths.WORKSPACE_ROOT, "RUNS_DIR": paths.RUNS_DIR, + "DATA_DIR": paths.DATA_DIR, "MEMORIES_DIR": paths.MEMORIES_DIR, "MEMORY_DIR": paths.MEMORY_DIR, + "GLOBAL_SKILLS_DIR": paths.GLOBAL_SKILLS_DIR, + "GLOBAL_MEMORIES_DIR": paths.GLOBAL_MEMORIES_DIR, "USER_SKILLS_DIR": paths.USER_SKILLS_DIR, "_active_workspace": paths._active_workspace, } yield paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"] paths.RUNS_DIR = orig["RUNS_DIR"] + paths.DATA_DIR = orig["DATA_DIR"] paths.MEMORIES_DIR = orig["MEMORIES_DIR"] paths.MEMORY_DIR = orig["MEMORY_DIR"] + paths.GLOBAL_SKILLS_DIR = orig["GLOBAL_SKILLS_DIR"] + paths.GLOBAL_MEMORIES_DIR = orig["GLOBAL_MEMORIES_DIR"] paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"] paths._active_workspace = orig["_active_workspace"] @@ -120,3 +126,125 @@ class TestEnsureDirsUsesUpdatedPaths: assert not ( new_root / "skills" ).exists() # skills created on demand by install_skill() + + +class TestDataDir: + """Tests for DATA_DIR and global data-dir helpers.""" + + def test_global_skills_dir_under_data_dir(self): + """GLOBAL_SKILLS_DIR must live under DATA_DIR.""" + assert paths.GLOBAL_SKILLS_DIR == paths.DATA_DIR / "skills" + + def test_global_memories_dir_under_data_dir(self): + """GLOBAL_MEMORIES_DIR must live under DATA_DIR.""" + assert paths.GLOBAL_MEMORIES_DIR == paths.DATA_DIR / "memories" + + +class TestLegacySessionsDbMigration: + """Tests for migrate_legacy_sessions_db() — transitional helper. + + Tests redirect ``DATA_DIR`` and ``Path.home()`` so the real user home + is never touched. + """ + + def _setup(self, tmp_path, monkeypatch): + """Redirect data dir to tmp_path/new_data and Path.home() to + tmp_path/fake_home so legacy resolves to tmp_path/fake_home/.config/evoscientist. + + Clears XDG_CONFIG_HOME so the legacy resolver deterministically uses + the Path.home() fallback. Tests that want the XDG branch set the + env var explicitly. + """ + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + data_dir = tmp_path / "new_data" + fake_home = tmp_path / "fake_home" + fake_home.mkdir() + legacy_dir = fake_home / ".config" / "evoscientist" + monkeypatch.setattr(paths, "DATA_DIR", data_dir) + monkeypatch.setattr(paths.Path, "home", classmethod(lambda cls: fake_home)) + return data_dir, legacy_dir + + def test_copies_sqlite_trio_when_legacy_exists(self, tmp_path, monkeypatch): + """All three SQLite files should be copied to the new location.""" + data_dir, legacy_dir = self._setup(tmp_path, monkeypatch) + legacy_dir.mkdir(parents=True) + for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"): + (legacy_dir / name).write_bytes(b"stub-" + name.encode()) + + paths.migrate_legacy_sessions_db() + + for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"): + assert (data_dir / name).read_bytes() == b"stub-" + name.encode() + # Legacy files must remain (copy, not move) + assert (legacy_dir / name).exists() + assert (data_dir / ".migrated").exists() + + def test_idempotent_via_marker(self, tmp_path, monkeypatch): + """Once .migrated exists, migration should be a no-op.""" + data_dir, legacy_dir = self._setup(tmp_path, monkeypatch) + data_dir.mkdir() + (data_dir / ".migrated").touch() + legacy_dir.mkdir(parents=True) + (legacy_dir / "sessions.db").write_bytes(b"should-not-copy") + + paths.migrate_legacy_sessions_db() + + assert not (data_dir / "sessions.db").exists() + + def test_no_legacy_just_creates_marker(self, tmp_path, monkeypatch): + """When legacy dir doesn't exist, only the marker is created.""" + data_dir, _ = self._setup(tmp_path, monkeypatch) + + paths.migrate_legacy_sessions_db() + + assert data_dir.is_dir() + assert (data_dir / ".migrated").exists() + assert not (data_dir / "sessions.db").exists() + + def test_does_not_overwrite_existing_files(self, tmp_path, monkeypatch): + """If new location already has a file, don't overwrite it.""" + data_dir, legacy_dir = self._setup(tmp_path, monkeypatch) + data_dir.mkdir() + (data_dir / "sessions.db").write_bytes(b"new-content-keep") + legacy_dir.mkdir(parents=True) + (legacy_dir / "sessions.db").write_bytes(b"legacy-content") + + paths.migrate_legacy_sessions_db() + + assert (data_dir / "sessions.db").read_bytes() == b"new-content-keep" + + def test_respects_xdg_config_home(self, tmp_path, monkeypatch): + """Legacy source must honor XDG_CONFIG_HOME so users who customize it + don't silently get skipped by the migration.""" + data_dir = tmp_path / "new_data" + xdg = tmp_path / "xdg" + legacy_dir = xdg / "evoscientist" + legacy_dir.mkdir(parents=True) + (legacy_dir / "sessions.db").write_bytes(b"xdg-db") + + monkeypatch.setattr(paths, "DATA_DIR", data_dir) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + + paths.migrate_legacy_sessions_db() + + assert (data_dir / "sessions.db").read_bytes() == b"xdg-db" + + def test_marker_not_written_on_partial_failure(self, tmp_path, monkeypatch): + """If any copy fails, the .migrated marker must not be written.""" + data_dir, legacy_dir = self._setup(tmp_path, monkeypatch) + legacy_dir.mkdir(parents=True) + for name in ("sessions.db", "sessions.db-wal"): + (legacy_dir / name).write_bytes(b"ok") + + real_copy2 = paths.shutil.copy2 + + def flaky_copy2(src, dst, *args, **kwargs): + if str(src).endswith("sessions.db-wal"): + raise OSError("simulated I/O failure") + return real_copy2(src, dst, *args, **kwargs) + + with mock.patch.object(paths.shutil, "copy2", side_effect=flaky_copy2): + paths.migrate_legacy_sessions_db() + + assert (data_dir / "sessions.db").exists() # main db copied + assert not (data_dir / ".migrated").exists() # retry allowed diff --git a/tests/test_sessions.py b/tests/test_sessions.py index fa0f5f1..25d40b0 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -41,11 +41,10 @@ class TestGenerateThreadId(unittest.TestCase): class TestGetDbPath(unittest.TestCase): - def test_uses_config_dir(self): + def test_uses_data_dir(self): path = get_db_path() assert str(path).endswith("sessions.db") - assert ".config" in str(path) - assert "evoscientist" in str(path) + assert ".evoscientist" in str(path) class TestFormatRelativeTime(unittest.TestCase):