core/cli: scope sessions to workspace directory

Create/resume/list sessions for the current workspace; resume rejects
mismatched workspace bindings.
This commit is contained in:
2026-07-15 00:01:00 +08:00
parent 0a2dac2561
commit ede7dcccd6
4 changed files with 51 additions and 16 deletions
+3 -3
View File
@@ -48,7 +48,7 @@ TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]
### Memory (`memory/`) ### 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/`) ### 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`. 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`, … - 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 providers`**: list config providers.
- **`plyngent config path|edit`**: print or open config in `$EDITOR` (`shlex`-split, e.g. `codium --wait`). - **`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. - If no providers and `$EDITOR` is set, chat/providers prompt to edit config then reload.
+5 -1
View File
@@ -83,18 +83,22 @@ async def _run_chat(
tools_enabled=tools, tools_enabled=tools,
max_rounds=max_rounds, max_rounds=max_rounds,
) )
click.secho(f"workspace: {state.workspace}", fg="bright_black")
if session_id is not None: if session_id is not None:
await state.resume_session(session_id) await state.resume_session(session_id)
click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)") click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)")
elif new_session: elif new_session:
await state.new_session() await state.new_session()
click.echo(f"new session {state.session_id} (workspace={state.workspace})")
else: else:
mode = await state.resume_latest_or_new() mode = await state.resume_latest_or_new()
if mode == "resume": if mode == "resume":
click.echo( 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" 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) await run_repl(state)
finally: finally:
await memory.close() await memory.close()
+9 -6
View File
@@ -28,9 +28,9 @@ Commands:
/quit, /exit Leave the REPL /quit, /exit Leave the REPL
/clear Clear in-memory conversation (keeps session id) /clear Clear in-memory conversation (keeps session id)
/history [n] Show last n messages in this session (default 20) /history [n] Show last n messages in this session (default 20)
/sessions List sessions /sessions List sessions for this workspace
/new [name] Start a new session /new [name] Start a new session (bound to workspace)
/resume <id> Resume a session by id /resume <id> Resume a session by id (must match workspace)
/provider [name] Show or switch provider /provider [name] Show or switch provider
/model [id] Show or switch model /model [id] Show or switch model
/tools [on|off] Show or toggle tools /tools [on|off] Show or toggle tools
@@ -66,13 +66,16 @@ def _cmd_status(state: ReplState) -> None:
async def _cmd_sessions(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: if not sessions:
click.echo("(no sessions)") click.echo(f"(no sessions for workspace {state.workspace})")
return return
for session in sessions: for session in sessions:
marker = "*" if session.sid == state.session_id else " " 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: async def _cmd_new(state: ReplState, arg: str) -> None:
+34 -6
View File
@@ -1,19 +1,20 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.memory.database.store import normalize_workspace
from plyngent.runtime import create_client from plyngent.runtime import create_client
from plyngent.tools import DEFAULT_TOOLS from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
from plyngent.config.models import Provider from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow
@dataclass @dataclass
@@ -35,8 +36,16 @@ class ReplState:
def __post_init__(self) -> None: def __post_init__(self) -> None:
# DeepSeek client uses a compatible but distinct param type; treat as ChatClient. # DeepSeek client uses a compatible but distinct param type; treat as ChatClient.
self.client = cast("ChatClient", cast("object", create_client(self.provider))) self.client = cast("ChatClient", cast("object", create_client(self.provider)))
self.workspace = Path(self.workspace).expanduser().resolve()
self.agent = self._make_agent() 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: def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled: if not self.tools_enabled:
return None return None
@@ -79,8 +88,19 @@ class ReplState:
self.agent = self._make_agent() self.agent = self._make_agent()
self.agent.messages = messages 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: 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.session_id = session.sid
self.agent = self._make_agent() self.agent = self._make_agent()
self.agent.pending_retry_text = None self.agent.pending_retry_text = None
@@ -90,13 +110,21 @@ class ReplState:
if row is None: if row is None:
msg = f"session not found: {session_id}" msg = f"session not found: {session_id}"
raise ValueError(msg) 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.session_id = session_id
self.agent = self._make_agent() self.agent = self._make_agent()
await self.agent.load_history() await self.agent.load_history()
async def resume_latest_or_new(self, name: str = "chat") -> str: async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume the most recently updated session, or create one if none exist.""" """Resume latest session for this workspace, or create one if none exist."""
sessions = await self.memory.list_sessions() sessions = await self.memory.list_sessions(workspace=self.workspace)
if not sessions: if not sessions:
await self.new_session(name=name) await self.new_session(name=name)
return "new" return "new"