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/`)
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.
+5 -1
View File
@@ -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()
+9 -6
View File
@@ -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 <id> Resume a session by id
/sessions List sessions for this workspace
/new [name] Start a new session (bound to workspace)
/resume <id> 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:
+34 -6
View File
@@ -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"