From ede7dcccd68dd7656fce263e844bbc0292e371a5 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 15 Jul 2026 00:01:00 +0800 Subject: [PATCH] core/cli: scope sessions to workspace directory Create/resume/list sessions for the current workspace; resume rejects mismatched workspace bindings. --- CLAUDE.md | 6 +++--- src/plyngent/cli/app.py | 6 +++++- src/plyngent/cli/repl.py | 15 +++++++++------ src/plyngent/cli/state.py | 40 +++++++++++++++++++++++++++++++++------ 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32b7703..08b51ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database] ### Memory (`memory/`) -Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init, default local user, sessions, messages stored as msgspec chat message JSON. +Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns), default local user, sessions (bound to `workspace` path), messages stored as msgspec chat message JSON. ### Agent (`agent/`) @@ -75,9 +75,9 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use. 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:`), resumes latest session by default (`--new` / `--session`). +- **`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 latest **for cwd/`--workspace`** by default (`--new` / `--session`). - Slash: `/history`, `/sessions`, `/resume`, `/rounds`, `/retry`, … -- Failed/cancelled turns: not written to DB; Ctrl+C cancels the in-flight turn task; auto-retry 10s/20s/30s (Ctrl+C cancels wait); `/retry` manual. +- Failed/cancelled turns: not written to DB; Ctrl+C cancels the in-flight turn task; **TTY confirms** (max-rounds / destructive tools) pause cancel so prompts work; auto-retry 10s/20s/30s; `/retry` manual. - **`plyngent providers`**: list config providers. - **`plyngent config path|edit`**: print or open config in `$EDITOR` (`shlex`-split, e.g. `codium --wait`). - If no providers and `$EDITOR` is set, chat/providers prompt to edit config then reload. diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index eb1d1e8..4427c79 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -83,18 +83,22 @@ async def _run_chat( tools_enabled=tools, max_rounds=max_rounds, ) + click.secho(f"workspace: {state.workspace}", fg="bright_black") if session_id is not None: await state.resume_session(session_id) click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)") elif new_session: await state.new_session() + click.echo(f"new session {state.session_id} (workspace={state.workspace})") else: mode = await state.resume_latest_or_new() if mode == "resume": click.echo( - f"resumed latest session {state.session_id} " + f"resumed latest session {state.session_id} for this workspace " f"({len(state.agent.messages)} messages); use --new for a fresh chat" ) + else: + click.echo(f"new session {state.session_id} (workspace={state.workspace})") await run_repl(state) finally: await memory.close() diff --git a/src/plyngent/cli/repl.py b/src/plyngent/cli/repl.py index 87e2854..ed655db 100644 --- a/src/plyngent/cli/repl.py +++ b/src/plyngent/cli/repl.py @@ -28,9 +28,9 @@ Commands: /quit, /exit Leave the REPL /clear Clear in-memory conversation (keeps session id) /history [n] Show last n messages in this session (default 20) - /sessions List sessions - /new [name] Start a new session - /resume Resume a session by id + /sessions List sessions for this workspace + /new [name] Start a new session (bound to workspace) + /resume Resume a session by id (must match workspace) /provider [name] Show or switch provider /model [id] Show or switch model /tools [on|off] Show or toggle tools @@ -66,13 +66,16 @@ def _cmd_status(state: ReplState) -> None: async def _cmd_sessions(state: ReplState) -> None: - sessions = await state.memory.list_sessions() + sessions = await state.memory.list_sessions(workspace=state.workspace) if not sessions: - click.echo("(no sessions)") + click.echo(f"(no sessions for workspace {state.workspace})") return for session in sessions: marker = "*" if session.sid == state.session_id else " " - click.echo(f"{marker} {session.sid}\t{session.name}\tupdated={session.updated_at}") + ws = session.workspace or "(unbound)" + click.echo( + f"{marker} {session.sid}\t{session.name}\tworkspace={ws}\tupdated={session.updated_at}" + ) async def _cmd_new(state: ReplState, arg: str) -> None: diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 39a5d60..347cf53 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -1,19 +1,20 @@ from __future__ import annotations from dataclasses import dataclass, field +from pathlib import Path from typing import TYPE_CHECKING, cast from plyngent.agent import ChatAgent, ChatClient, ToolRegistry from plyngent.agent.loop import DEFAULT_MAX_ROUNDS +from plyngent.memory.database.store import normalize_workspace from plyngent.runtime import create_client -from plyngent.tools import DEFAULT_TOOLS +from plyngent.tools import DEFAULT_TOOLS, set_workspace_root if TYPE_CHECKING: - from pathlib import Path - from plyngent.config.models import Provider from plyngent.config.store import ConfigStore from plyngent.memory import MemoryStore + from plyngent.memory.database.schema import Session as SessionRow @dataclass @@ -35,8 +36,16 @@ class ReplState: def __post_init__(self) -> None: # DeepSeek client uses a compatible but distinct param type; treat as ChatClient. self.client = cast("ChatClient", cast("object", create_client(self.provider))) + self.workspace = Path(self.workspace).expanduser().resolve() self.agent = self._make_agent() + def _workspace_key(self) -> str: + key = normalize_workspace(self.workspace) + if key is None: + msg = "workspace path is required" + raise RuntimeError(msg) + return key + def _tool_registry(self) -> ToolRegistry | None: if not self.tools_enabled: return None @@ -79,8 +88,19 @@ class ReplState: self.agent = self._make_agent() self.agent.messages = messages + def _apply_session_workspace(self, row: SessionRow) -> None: + """Bind tools/REPL workspace to the session's directory when set.""" + if not row.workspace: + return + path = Path(row.workspace).expanduser().resolve() + if not path.is_dir(): + msg = f"session {row.sid} workspace is not a directory: {path}" + raise ValueError(msg) + self.workspace = path + _ = set_workspace_root(path) + async def new_session(self, name: str = "chat") -> None: - session = await self.memory.create_session(name=name) + session = await self.memory.create_session(name=name, workspace=self.workspace) self.session_id = session.sid self.agent = self._make_agent() self.agent.pending_retry_text = None @@ -90,13 +110,21 @@ class ReplState: if row is None: msg = f"session not found: {session_id}" raise ValueError(msg) + expected = self._workspace_key() + if row.workspace is not None and row.workspace != expected: + msg = ( + f"session {session_id} is bound to workspace {row.workspace!r}, " + f"not current {expected!r} (use matching --workspace or /resume from that dir)" + ) + raise ValueError(msg) + self._apply_session_workspace(row) self.session_id = session_id self.agent = self._make_agent() await self.agent.load_history() async def resume_latest_or_new(self, name: str = "chat") -> str: - """Resume the most recently updated session, or create one if none exist.""" - sessions = await self.memory.list_sessions() + """Resume latest session for this workspace, or create one if none exist.""" + sessions = await self.memory.list_sessions(workspace=self.workspace) if not sessions: await self.new_session(name=name) return "new"