diff --git a/CLAUDE.md b/CLAUDE.md index 09dea2c..107eb09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,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 selection (flags or interactive), SQLite sessions via config `[database]` (file DB under user data if unset/`:memory:`), sessions bound to workspace dir; resumes **most recently updated** session for cwd/`--workspace` by default (`--new` / `--session`). -- Slash: `/history`, `/sessions` (newest first), `/resume [id]`, `/compact`, `/status` (incl. context char estimate), `/rounds`, `/stream`, `/verbose`, `/retry`, … +- Slash: `/history`, `/sessions` (newest first), `/resume [id]`, `/rename`, `/delete` (confirm), `/export md|json`, `/compact`, `/status`, `/rounds`, `/stream`, `/verbose`, `/retry`, … - Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**. - Failed/cancelled turns: user message kept in DB; partial assistant/tool rolled back; Ctrl+C cancels in-flight turn; **TTY confirms** off-loop; auto-retry 10s/20s/30s then `/retry` (no duplicate user message). - **`plyngent providers`**: list config providers. diff --git a/src/plyngent/cli/export.py b/src/plyngent/cli/export.py new file mode 100644 index 0000000..cc35973 --- /dev/null +++ b/src/plyngent/cli/export.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import msgspec +from msgspec import UNSET + +from plyngent.lmproto.openai_compatible.model import ( + AssistantChatMessage, + AssistantFunctionToolCall, + ToolChatMessage, + UserChatMessage, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + from datetime import datetime + from pathlib import Path + + from plyngent.lmproto.openai_compatible.model import AnyChatMessage + + +def _iso(value: datetime | None) -> str | None: + if value is None: + return None + return value.isoformat() + + +def session_export_payload( + *, + sid: int, + name: str, + workspace: str | None, + created_at: datetime | None, + updated_at: datetime | None, + messages: Sequence[AnyChatMessage], +) -> dict[str, object]: + """Build a JSON-serializable dict for a session transcript.""" + return { + "session_id": sid, + "name": name, + "workspace": workspace, + "created_at": _iso(created_at), + "updated_at": _iso(updated_at), + "messages": [msgspec.to_builtins(m) for m in messages], + } + + +def encode_session_export_json(payload: dict[str, object]) -> str: + return msgspec.json.encode(payload).decode() + + +def format_session_export_md( + messages: Sequence[AnyChatMessage], + *, + sid: int, + name: str, + workspace: str | None = None, +) -> str: + """Render a simple markdown transcript.""" + lines: list[str] = [f"# Session {sid}: {name}"] + if workspace: + lines.append(f"workspace: `{workspace}`") + lines.append("") + for message in messages: + lines.extend(_format_message_md(message)) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def _format_message_md(message: AnyChatMessage) -> list[str]: + if isinstance(message, UserChatMessage): + return ["## user", "", message.content] + if isinstance(message, AssistantChatMessage): + parts: list[str] = ["## assistant", ""] + reasoning = message.reasoning_content + if isinstance(reasoning, str) and reasoning: + parts.extend(["### reasoning", "", reasoning, ""]) + if isinstance(message.content, str) and message.content: + parts.append(message.content) + tool_calls = message.tool_calls + if tool_calls is not UNSET and tool_calls: + names: list[str] = [] + for call in tool_calls: + if isinstance(call, AssistantFunctionToolCall): + names.append(call.function.name) + else: + names.append("custom") + parts.append(f"*tool_calls: {', '.join(names)}*") + # Header only ("## assistant", "") with no body yet. + if len(parts) <= 2: # noqa: PLR2004 + parts.append("(empty)") + return parts + if isinstance(message, ToolChatMessage): + return [f"## tool (`{message.tool_call_id}`)", "", message.content] + role = getattr(message, "role", type(message).__name__) + content = getattr(message, "content", "") + return [f"## {role}", "", str(content)] + + +def resolve_export_path(sid: int, fmt: str, path_arg: str | None) -> Path: + from pathlib import Path + + if path_arg: + return Path(path_arg).expanduser() + ext = "md" if fmt == "md" else "json" + return Path.cwd() / f"session-{sid}.{ext}" + + +def write_export_file(path: Path, text: str) -> Path: + """Write export text to ``path`` (sync helper; CLI is not on hot async I/O).""" + _ = path.parent.mkdir(parents=True, exist_ok=True) + _ = path.write_text(text, encoding="utf-8") + return path.resolve() diff --git a/src/plyngent/cli/readline_setup.py b/src/plyngent/cli/readline_setup.py index f3f4bd4..4a8aed6 100644 --- a/src/plyngent/cli/readline_setup.py +++ b/src/plyngent/cli/readline_setup.py @@ -24,6 +24,9 @@ SLASH_COMMANDS: tuple[str, ...] = ( "/sessions", "/new", "/resume", + "/rename", + "/delete", + "/export", "/compact", "/provider", "/model", @@ -36,6 +39,7 @@ SLASH_COMMANDS: tuple[str, ...] = ( ) _ON_OFF_ARGS: tuple[str, ...] = ("on", "off") +_EXPORT_ARGS: tuple[str, ...] = ("md", "json") def history_path() -> Path: @@ -78,6 +82,8 @@ def _argument_options(state: ReplState, command: str, text: str) -> list[str]: return filter_prefix(text, sorted(state.provider.models.keys())) if command in {"/tools", "/stream", "/verbose"}: return filter_prefix(text, list(_ON_OFF_ARGS)) + if command == "/export": + return filter_prefix(text, list(_EXPORT_ARGS)) if command == "/resume": return [] return [] diff --git a/src/plyngent/cli/repl.py b/src/plyngent/cli/repl.py index 1e3a635..3fabfc5 100644 --- a/src/plyngent/cli/repl.py +++ b/src/plyngent/cli/repl.py @@ -31,6 +31,9 @@ Commands: /sessions List sessions for this workspace (newest first) /new [name] Start a new session (bound to workspace) /resume [id] Resume session id, or latest for this workspace if omitted + /rename Rename the current session + /delete [id] Hard-delete a session (confirm; current → new empty) + /export [md|json] [path] Export session transcript from DB /compact [name] Soft-compact + model-summarize into a new session /provider [name] Show or switch provider /model [id] Show or switch model @@ -108,6 +111,113 @@ async def _cmd_new(state: ReplState, arg: str) -> None: click.echo(f"new session id={state.session_id} name={name}") +async def _cmd_rename(state: ReplState, arg: str) -> None: + name = arg.strip() + if not name: + click.echo("usage: /rename ") + return + try: + row = await state.rename_current_session(name) + except ValueError as exc: + click.echo(f"error: {exc}") + return + click.echo(f"renamed session {row.sid} -> {row.name}") + + +async def _cmd_delete(state: ReplState, arg: str) -> None: + from plyngent.prompting import NonInteractiveError, confirm_async + + token = arg.strip() + if token: + try: + sid = int(token) + except ValueError: + click.echo("usage: /delete [session id]") + return + else: + if state.session_id is None: + click.echo("error: no active session") + return + sid = state.session_id + try: + allowed = await confirm_async( + f"Permanently delete session {sid} and all messages?", + default=False, + ) + except NonInteractiveError: + click.echo("error: delete requires interactive confirm (or TTY)") + return + if not allowed: + click.echo("delete cancelled") + return + try: + was_current = await state.delete_session_and_maybe_replace(sid) + except ValueError as exc: + click.echo(f"error: {exc}") + return + if was_current: + click.echo(f"deleted session {sid}; new session {state.session_id}") + else: + click.echo(f"deleted session {sid}") + + +async def _cmd_export(state: ReplState, arg: str) -> None: + from plyngent.cli.export import ( + encode_session_export_json, + format_session_export_md, + resolve_export_path, + session_export_payload, + write_export_file, + ) + + if state.session_id is None: + click.echo("error: no active session") + return + parts = arg.split() + fmt = "md" + path_arg: str | None = None + if parts: + first = parts[0].lower() + if first in {"md", "markdown", "json"}: + fmt = "json" if first == "json" else "md" + path_arg = parts[1] if len(parts) > 1 else None + else: + path_arg = parts[0] + if len(parts) > 1: + click.echo("usage: /export [md|json] [path]") + return + row = await state.memory.get_session(state.session_id) + if row is None: + click.echo(f"error: session not found: {state.session_id}") + return + messages = await state.memory.list_messages(state.session_id) + out_path = resolve_export_path(state.session_id, fmt, path_arg) + if fmt == "json": + text = encode_session_export_json( + session_export_payload( + sid=row.sid, + name=row.name, + workspace=row.workspace, + created_at=row.created_at, + updated_at=row.updated_at, + messages=messages, + ) + ) + else: + text = format_session_export_md( + messages, + sid=row.sid, + name=row.name, + workspace=row.workspace, + ) + try: + written = write_export_file(out_path, text) + except OSError as exc: + click.echo(f"error: write failed: {exc}") + return + click.echo(f"exported session {row.sid} ({fmt}) -> {written}") + + async def _cmd_resume(state: ReplState, arg: str) -> None: if not arg.strip(): mode = await state.resume_latest_or_new() @@ -321,6 +431,9 @@ async def _dispatch_slash(state: ReplState, command: str, arg: str) -> bool: "history": lambda: _cmd_history(state, arg), "sessions": lambda: _cmd_sessions(state), "new": lambda: _cmd_new(state, arg), + "rename": lambda: _cmd_rename(state, arg), + "delete": lambda: _cmd_delete(state, arg), + "export": lambda: _cmd_export(state, arg), "resume": lambda: _cmd_resume(state, arg), "compact": lambda: _cmd_compact(state, arg), "provider": lambda: _cmd_provider(state, arg), diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 6cffb80..741b818 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -120,6 +120,26 @@ class ReplState: self.session_id = session.sid self.agent = self._make_agent() + async def rename_current_session(self, name: str) -> SessionRow: + if self.session_id is None: + msg = "no active session" + raise ValueError(msg) + return await self.memory.rename_session(self.session_id, name) + + async def delete_session_and_maybe_replace(self, sid: int) -> bool: + """Hard-delete ``sid``. If it was current, start a new empty session. + + Returns True when the deleted session was the active one. + """ + was_current = self.session_id == sid + ok = await self.memory.delete_session(sid) + if not ok: + msg = f"session not found: {sid}" + raise ValueError(msg) + if was_current: + await self.new_session() + return was_current + async def resume_session(self, session_id: int) -> None: """Load a session; on workspace mismatch, prompt keep / rebind / abort.""" from plyngent.cli.limits import prompt_workspace_mismatch diff --git a/src/plyngent/memory/database/store.py b/src/plyngent/memory/database/store.py index d467473..3471814 100644 --- a/src/plyngent/memory/database/store.py +++ b/src/plyngent/memory/database/store.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Self import msgspec -from sqlalchemy import select, text +from sqlalchemy import delete, select, text from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from plyngent.config.models import DatabaseConfig @@ -21,6 +21,7 @@ if TYPE_CHECKING: DEFAULT_USER_NAME = "local" DEFAULT_USER_EMAIL = "local@localhost" DEFAULT_USER_PASSWORD_HASH = "" +_SESSION_NAME_MAX = 64 def normalize_workspace(path: str | Path | None) -> str | None: @@ -175,6 +176,38 @@ class MemoryStore: await session.refresh(row) return row + async def rename_session(self, sid: int, name: str) -> Session: + """Rename a session (max 64 characters, non-empty after strip).""" + cleaned = name.strip() + if not cleaned: + msg = "session name must be non-empty" + raise ValueError(msg) + if len(cleaned) > _SESSION_NAME_MAX: + msg = f"session name too long (max {_SESSION_NAME_MAX})" + raise ValueError(msg) + async with self._session_factory() as session: + row = await session.get(Session, sid) + if row is None: + msg = f"session not found: {sid}" + raise ValueError(msg) + row.name = cleaned + row.updated_at = datetime.now(UTC) + await session.commit() + await session.refresh(row) + return row + + async def delete_session(self, sid: int) -> bool: + """Hard-delete a session and its messages. Returns False if missing.""" + async with self._session_factory() as session: + row = await session.get(Session, sid) + if row is None: + return False + # Explicit message delete: SQLite FK cascade may be off without PRAGMA. + _ = await session.execute(delete(Message).where(Message.sid == sid)) + await session.delete(row) + await session.commit() + return True + async def append_message(self, sid: int, message: AnyChatMessage) -> Message: """Append a chat message to a session with the next sequence number.""" data = msgspec.to_builtins(message) diff --git a/tests/test_cli/test_export.py b/tests/test_cli/test_export.py new file mode 100644 index 0000000..060c68e --- /dev/null +++ b/tests/test_cli/test_export.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from plyngent.cli.export import ( + encode_session_export_json, + format_session_export_md, + session_export_payload, +) +from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage + + +def test_format_session_export_md() -> None: + text = format_session_export_md( + [ + UserChatMessage(content="hi"), + AssistantChatMessage(content="yo", reasoning_content="plan"), + ], + sid=7, + name="demo", + workspace="/tmp/ws", + ) + assert "# Session 7: demo" in text + assert "## user" in text + assert "hi" in text + assert "### reasoning" in text + assert "plan" in text + assert "yo" in text + + +def test_session_export_json_roundtrip() -> None: + messages = [UserChatMessage(content="a")] + now = datetime.now(UTC) + payload = session_export_payload( + sid=7, + name="demo", + workspace="/tmp/ws", + created_at=now, + updated_at=now, + messages=messages, + ) + assert isinstance(payload, dict) + raw = encode_session_export_json(payload) + assert "7" in raw + assert "demo" in raw diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index 2711d16..18e78e7 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -116,6 +116,64 @@ async def test_tools_toggle(state: ReplState) -> None: assert state.tools_enabled is False +async def test_rename_slash(state: ReplState) -> None: + sid = state.session_id + assert sid is not None + assert await handle_slash(state, "/rename my-chat") is True + row = await state.memory.get_session(sid) + assert row is not None + assert row.name == "my-chat" + + +async def test_delete_slash_confirm( + state: ReplState, + capsys: pytest.CaptureFixture[str], +) -> None: + from plyngent.prompting import temporary_backend + from tests.test_prompting import ScriptedBackend + + # Delete a non-current session so SQLite cannot reuse the same sid as "current". + victim = state.session_id + assert victim is not None + assert await handle_slash(state, "/new keep") is True + current = state.session_id + assert current != victim + + with temporary_backend(ScriptedBackend([], confirms=[False])): + assert await handle_slash(state, f"/delete {victim}") is True + assert await state.memory.get_session(victim) is not None + assert "cancelled" in capsys.readouterr().out + + with temporary_backend(ScriptedBackend([], confirms=[True])): + assert await handle_slash(state, f"/delete {victim}") is True + assert await state.memory.get_session(victim) is None + assert state.session_id == current + out = capsys.readouterr().out + assert "deleted" in out + assert "new session" not in out + + +async def test_export_slash(state: ReplState, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage + + assert state.session_id is not None + _ = await state.memory.append_message(state.session_id, UserChatMessage(content="hi")) + _ = await state.memory.append_message(state.session_id, AssistantChatMessage(content="yo")) + path = tmp_path / "out.md" + assert await handle_slash(state, f"/export md {path}") is True + text = path.read_text(encoding="utf-8") + assert "Session" in text + assert "hi" in text + assert "yo" in text + assert str(path.resolve()) in capsys.readouterr().out + + jpath = tmp_path / "out.json" + assert await handle_slash(state, f"/export json {jpath}") is True + raw = jpath.read_text(encoding="utf-8") + assert '"session_id"' in raw + assert "hi" in raw + + async def test_stream_toggle(state: ReplState) -> None: assert state.agent.stream is True assert await handle_slash(state, "/stream off") is True diff --git a/tests/test_memory/test_store.py b/tests/test_memory/test_store.py index 97c8efc..6ebb8b4 100644 --- a/tests/test_memory/test_store.py +++ b/tests/test_memory/test_store.py @@ -98,6 +98,37 @@ async def test_session_workspace_binding(store: MemoryStore, tmp_path: object) - assert {s.sid for s in listed_b} == {sb.sid} +async def test_rename_session(store: MemoryStore) -> None: + session = await store.create_session(name="old") + updated = await store.rename_session(session.sid, " new name ") + assert updated.name == "new name" + again = await store.get_session(session.sid) + assert again is not None + assert again.name == "new name" + try: + await store.rename_session(session.sid, " ") + raise AssertionError("expected ValueError") + except ValueError as exc: + assert "non-empty" in str(exc) + try: + await store.rename_session(999_999, "x") + raise AssertionError("expected ValueError") + except ValueError as exc: + assert "not found" in str(exc) + + +async def test_delete_session_cascades_messages(store: MemoryStore) -> None: + from plyngent.lmproto.openai_compatible.model import UserChatMessage + + session = await store.create_session(name="gone") + _ = await store.append_message(session.sid, UserChatMessage(content="a")) + _ = await store.append_message(session.sid, UserChatMessage(content="b")) + assert await store.delete_session(session.sid) is True + assert await store.get_session(session.sid) is None + assert await store.list_messages(session.sid) == [] + assert await store.delete_session(session.sid) is False + + async def test_update_session_workspace(store: MemoryStore, tmp_path: object) -> None: from pathlib import Path