diff --git a/CLAUDE.md b/CLAUDE.md index 5bd6995..ed721e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. -- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data if unset/`:memory:`); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`. +- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`. - Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$EDITOR`. `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. - Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. - Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`. diff --git a/README.md b/README.md index da6a46f..5e26bfa 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ max_context_tokens = 200000 Supported provider presets today: `openai` (default if `preset` is omitted; default models `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` when `models` is omitted), `openai-compatible`, `deepseek` (OpenAI convention; default models `deepseek-v4-flash` and `deepseek-v4-pro` if `models` is omitted). Anthropic presets are modeled in config but not wired in the runtime client yet. -If `[database]` is omitted (or SQLite `url` is empty/`":memory:"`), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux). +If `[database]` is omitted (or SQLite `url` is unset/empty), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux). Set `url = ":memory:"` for a true in-memory SQLite (CLI warns; no file; useful for tests). ## Chat diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 0873dd8..13470d4 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -2,11 +2,13 @@ # Copy to the path shown by: plyngent config path # Then set access_key_or_token and adjust models. -# Optional. If omitted (or SQLite url is empty / ":memory:"), the CLI uses a -# durable file under the platform user data dir (e.g. ~/.local/share/plyngent/chat.db). +# Optional. If omitted (or SQLite url unset/empty), the CLI uses a durable file under +# the platform user data dir (e.g. ~/.local/share/plyngent/chat.db). +# url = ":memory:" → true in-memory SQLite (CLI warns; useful for tests). # [database] # implementation = "sqlite" # url = "/path/to/chat.db" +# # url = ":memory:" [agent] system_prompt = "You are a careful coding assistant. Prefer small, verified edits." diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index 205649a..5ed3a3b 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -82,12 +82,21 @@ def _warn_recoverable_providers(recoverable: Mapping[str, object]) -> None: def _database_config(store: ConfigStore, *, quiet: bool = False) -> DatabaseConfig: raw = dict(store.database) - # Prefer a durable file DB so sessions survive CLI restarts. - if raw.get("url") in {None, "", ":memory:"} and raw.get("implementation", "sqlite") == "sqlite": + url = raw.get("url") + impl = raw.get("implementation", "sqlite") + # Unset/empty → durable user-data chat.db so interactive sessions persist. + # Explicit ``:memory:`` is kept as-is (ephemeral; warn so it is not accidental). + if impl == "sqlite" and url in {None, ""}: db_path = user_data_path("plyngent", ensure_exists=True) / _DEFAULT_DB_FILENAME raw = {**raw, "implementation": "sqlite", "url": str(db_path)} if not quiet: click.secho(f"using database: {db_path}", fg="bright_black", err=True) + elif not quiet and impl == "sqlite" and url == ":memory:": + click.secho( + "warning: database url is :memory: — sessions are not persisted to disk", + fg="yellow", + err=True, + ) return msgspec.convert(raw, DatabaseConfig) diff --git a/src/plyngent/cli/editor.py b/src/plyngent/cli/editor.py index 603c669..0ea9744 100644 --- a/src/plyngent/cli/editor.py +++ b/src/plyngent/cli/editor.py @@ -18,10 +18,12 @@ _MINIMAL_CONFIG = """\ # plyngent configuration # edit providers below -# Optional: omit [database] to use ~/.local/share/plyngent/chat.db (Linux). +# Optional: omit [database] (or leave url unset) → ~/.local/share/plyngent/chat.db. +# url = ":memory:" keeps a true in-memory SQLite (CLI warns; nothing on disk). # [database] # implementation = "sqlite" # url = "/path/to/chat.db" +# # url = ":memory:" # [agent] # system_prompt = "You are a careful coding assistant." diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index cdc89c5..2e319f8 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -4,10 +4,15 @@ from msgspec import Struct, field class DatabaseConfig(Struct, omit_defaults=True): - """Database connection configuration.""" + """Database connection configuration. + + ``url`` is ``None`` when unset. The CLI fills a durable user-data + ``chat.db`` only for unset/empty url. Explicit ``":memory:"`` is a true + in-memory SQLite (no rewrite). + """ implementation: str = "sqlite" - url: str = ":memory:" + url: str | None = None username: str | None = None password: str | None = None diff --git a/src/plyngent/memory/database/engine.py b/src/plyngent/memory/database/engine.py index 47039ca..c9e6fe2 100644 --- a/src/plyngent/memory/database/engine.py +++ b/src/plyngent/memory/database/engine.py @@ -20,7 +20,7 @@ def build_async_url(config: DatabaseConfig) -> str: raise UnsupportedDatabaseError(msg) url = config.url - if url in {":memory:", ""}: + if url is None or url in {":memory:", ""}: return "sqlite+aiosqlite:///:memory:" if url.startswith("sqlite+aiosqlite://"): return url diff --git a/tests/test_cli/test_app.py b/tests/test_cli/test_app.py index d19bd6a..8f51414 100644 --- a/tests/test_cli/test_app.py +++ b/tests/test_cli/test_app.py @@ -1,10 +1,102 @@ -from __future__ import annotations - from pathlib import Path +import pytest from click.testing import CliRunner -from plyngent.cli.app import main +from plyngent.cli.app import _database_config, main +from plyngent.config import load as load_config + + +def test_database_config_memory_url_kept_with_warning( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Explicit ``url = ":memory:"`` is not rewritten; CLI warns (when not quiet).""" + path = tmp_path / "mem.toml" + _ = path.write_text( + """ +[database] +implementation = "sqlite" +url = ":memory:" +""", + encoding="utf-8", + ) + store = load_config(path) + cfg = _database_config(store, quiet=False) + assert cfg.url == ":memory:" + assert cfg.implementation == "sqlite" + err = capsys.readouterr().err + assert ":memory:" in err + assert "not persisted" in err.lower() or "warning" in err.lower() + + +def test_database_config_memory_url_quiet_no_warn(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + path = tmp_path / "mem.toml" + _ = path.write_text( + """ +[database] +implementation = "sqlite" +url = ":memory:" +""", + encoding="utf-8", + ) + store = load_config(path) + cfg = _database_config(store, quiet=True) + assert cfg.url == ":memory:" + assert capsys.readouterr().err == "" + + +def test_database_config_unset_url_uses_user_data( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unset/empty url → durable user-data chat.db.""" + data_dir = tmp_path / "user-data" + _ = data_dir.mkdir() + + def fake_user_data_path(*_args: object, **_kwargs: object) -> Path: + return data_dir + + monkeypatch.setattr("plyngent.cli.app.user_data_path", fake_user_data_path) + path = tmp_path / "empty-url.toml" + _ = path.write_text( + """ +[database] +implementation = "sqlite" +""", + encoding="utf-8", + ) + store = load_config(path) + cfg = _database_config(store, quiet=True) + assert cfg.url == str(data_dir / "chat.db") + + +def test_database_config_omitted_section_uses_user_data( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Omitted [database] → durable user-data chat.db (url default None).""" + data_dir = tmp_path / "user-data2" + _ = data_dir.mkdir() + + def fake_user_data_path(*_args: object, **_kwargs: object) -> Path: + return data_dir + + monkeypatch.setattr("plyngent.cli.app.user_data_path", fake_user_data_path) + path = tmp_path / "no-db.toml" + _ = path.write_text( + """ +[providers.local] +preset = "openai-compatible" +url = "http://127.0.0.1:1/v1" +access_key_or_token = "x" +""", + encoding="utf-8", + ) + store = load_config(path) + assert store.database.get("url") is None + cfg = _database_config(store, quiet=True) + assert cfg.url == str(data_dir / "chat.db") def test_providers_list() -> None: