mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user