mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/cli: Click slash registry with awaitlet bridge
Register REPL slash commands as a Click Group (params, UsageError, help). Dispatch via awaitlet.async_def so sync command bodies can awaitlet() async memory/confirm work. Completer and /help derive from the registry.
This commit is contained in:
@@ -84,7 +84,7 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
|
|||||||
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:`), sessions bound to workspace dir; resumes **most recently updated** session for cwd/`--workspace` 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 **most recently updated** session for cwd/`--workspace` by default (`--new` / `--session`).
|
||||||
- Slash: `/history`, `/sessions` (newest first), `/resume [id]`, `/rename`, `/delete` (confirm), `/export md|json`, `/compact`, `/status`, `/rounds`, `/stream`, `/verbose`, `/retry`, …
|
- Slash commands: Click group in `cli/slash.py` (params/help/`UsageError`); dispatch via `awaitlet.async_def` + `awaitlet()` for async work. Completer reads `slash.list_commands`.
|
||||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**.
|
- 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).
|
- 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.
|
- **`plyngent providers`**: list config providers.
|
||||||
@@ -107,7 +107,23 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
|
|||||||
- **No local tokenizer stage** for now.
|
- **No local tokenizer stage** for now.
|
||||||
- **Phase E**: tooling depth (grep/glob, VCS backends; prefer `edit_replace` / `edit_lineno` over model-generated patches).
|
- **Phase E**: tooling depth (grep/glob, VCS backends; prefer `edit_replace` / `edit_lineno` over model-generated patches).
|
||||||
- **Phase F (providers + usage v2)**: API `usage` + char-based estimate fallback; session/turn totals; `/status` + end-of-turn. Optional later: cost, real tokenizer.
|
- **Phase F (providers + usage v2)**: API `usage` + char-based estimate fallback; session/turn totals; `/status` + end-of-turn. Optional later: cost, real tokenizer.
|
||||||
- **Phase G (CLI polish + hardening)**: shared `prompting` (`ask`/`choose`/`form`) + chat tools; then display/session/export/multiline/one-shot; agent/tools hardening; README. Multi-tenant stays Phase H.
|
- **Phase G (CLI polish + hardening)** — single-user only; multi-tenant stays Phase H.
|
||||||
|
|
||||||
|
**Done**
|
||||||
|
- G0: `prompting` (`ask`/`choose`/`form`/`confirm`) + chat tools (`ask_user`/`choose_user`/`form_user`)
|
||||||
|
- G1: `ReasoningDeltaEvent`, `/stream`, `/verbose`
|
||||||
|
- G2: `/rename`, `/delete` (confirm), `/export md|json`
|
||||||
|
- G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands`
|
||||||
|
|
||||||
|
**Planned**
|
||||||
|
|
||||||
|
- **G3 — Input**: multiline (`"""` … `"""`) and/or `/edit` via `$EDITOR`
|
||||||
|
- **G4 — One-shot**: `plyngent chat -p/--prompt` (and stdin non-TTY); exit codes; non-interactive safety (`--yes` / deny destructive)
|
||||||
|
- **G5 — Hardening**: tool timeouts/defaults review, config error clarity, cancel edges, optional `--log-level`, no secrets in status/export
|
||||||
|
- **G6 — Docs**: real README, example TOML, CLAUDE/help accuracy
|
||||||
|
|
||||||
|
Milestone order: G3 → G4 → G5 → G6.
|
||||||
|
|
||||||
- **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web).
|
- **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web).
|
||||||
|
|
||||||
## Commit messages
|
## Commit messages
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
groups = ["default", "dev"]
|
groups = ["default", "dev"]
|
||||||
strategy = ["inherit_metadata"]
|
strategy = ["inherit_metadata"]
|
||||||
lock_version = "4.5.0"
|
lock_version = "4.5.0"
|
||||||
content_hash = "sha256:a27f943f7f5204047e0fe0e99b1080301877e231ec01cabee48c3a79473ca676"
|
content_hash = "sha256:cd5131c309485626148d1933a2a557a9cb6a41038b980bc26a453def4c38032c"
|
||||||
|
|
||||||
[[metadata.targets]]
|
[[metadata.targets]]
|
||||||
requires_python = "~=3.14"
|
requires_python = "~=3.14"
|
||||||
@@ -21,6 +21,21 @@ files = [
|
|||||||
{file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"},
|
{file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "awaitlet"
|
||||||
|
version = "0.0.1"
|
||||||
|
requires_python = ">=3.8"
|
||||||
|
summary = "Allow non-async defs that invoke awaitables inside of asyncio applications"
|
||||||
|
groups = ["default"]
|
||||||
|
dependencies = [
|
||||||
|
"greenlet>=1",
|
||||||
|
"typing-extensions>=4.6.0",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "awaitlet-0.0.1-py3-none-any.whl", hash = "sha256:255b1c086f2fbfef2d8d88b66acae8636418d38bcfe93a897ccd8234a7b8bf6d"},
|
||||||
|
{file = "awaitlet-0.0.1.tar.gz", hash = "sha256:d98c8b821f595284f9327d45067995ccbee0d99993f0d0b935b23ac8ecf58334"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "basedpyright"
|
name = "basedpyright"
|
||||||
version = "1.39.9"
|
version = "1.39.9"
|
||||||
@@ -98,7 +113,7 @@ version = "0.4.6"
|
|||||||
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||||
summary = "Cross-platform colored terminal text."
|
summary = "Cross-platform colored terminal text."
|
||||||
groups = ["default", "dev"]
|
groups = ["default", "dev"]
|
||||||
marker = "sys_platform == \"win32\" or platform_system == \"Windows\""
|
marker = "platform_system == \"Windows\" or sys_platform == \"win32\""
|
||||||
files = [
|
files = [
|
||||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||||
@@ -110,7 +125,6 @@ version = "3.5.3"
|
|||||||
requires_python = ">=3.10"
|
requires_python = ">=3.10"
|
||||||
summary = "Lightweight in-process concurrent programming"
|
summary = "Lightweight in-process concurrent programming"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
marker = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""
|
|
||||||
files = [
|
files = [
|
||||||
{file = "greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117"},
|
{file = "greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117"},
|
||||||
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8"},
|
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8"},
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@ dependencies = [
|
|||||||
"tomlkit>=0.15.0",
|
"tomlkit>=0.15.0",
|
||||||
"sqlalchemy>=2.0.51",
|
"sqlalchemy>=2.0.51",
|
||||||
"aiosqlite>=0.22.1",
|
"aiosqlite>=0.22.1",
|
||||||
"click>=8.4.2"
|
"click>=8.4.2",
|
||||||
|
"awaitlet>=0.0.1"
|
||||||
]
|
]
|
||||||
requires-python = ">=3.14,<4"
|
requires-python = ">=3.14,<4"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
@@ -15,33 +15,17 @@ if TYPE_CHECKING:
|
|||||||
HISTORY_FILE_NAME = "repl_history"
|
HISTORY_FILE_NAME = "repl_history"
|
||||||
DEFAULT_HISTORY_LENGTH = 1000
|
DEFAULT_HISTORY_LENGTH = 1000
|
||||||
|
|
||||||
SLASH_COMMANDS: tuple[str, ...] = (
|
|
||||||
"/help",
|
|
||||||
"/quit",
|
|
||||||
"/exit",
|
|
||||||
"/clear",
|
|
||||||
"/history",
|
|
||||||
"/sessions",
|
|
||||||
"/new",
|
|
||||||
"/resume",
|
|
||||||
"/rename",
|
|
||||||
"/delete",
|
|
||||||
"/export",
|
|
||||||
"/compact",
|
|
||||||
"/provider",
|
|
||||||
"/model",
|
|
||||||
"/tools",
|
|
||||||
"/stream",
|
|
||||||
"/verbose",
|
|
||||||
"/rounds",
|
|
||||||
"/retry",
|
|
||||||
"/status",
|
|
||||||
)
|
|
||||||
|
|
||||||
_ON_OFF_ARGS: tuple[str, ...] = ("on", "off")
|
_ON_OFF_ARGS: tuple[str, ...] = ("on", "off")
|
||||||
_EXPORT_ARGS: tuple[str, ...] = ("md", "json")
|
_EXPORT_ARGS: tuple[str, ...] = ("md", "json")
|
||||||
|
|
||||||
|
|
||||||
|
def slash_commands() -> list[str]:
|
||||||
|
"""Slash command tokens for Tab completion (with leading /)."""
|
||||||
|
from plyngent.cli.slash import slash_command_names
|
||||||
|
|
||||||
|
return slash_command_names()
|
||||||
|
|
||||||
|
|
||||||
def history_path() -> Path:
|
def history_path() -> Path:
|
||||||
return user_data_path("plyngent", ensure_exists=True) / HISTORY_FILE_NAME
|
return user_data_path("plyngent", ensure_exists=True) / HISTORY_FILE_NAME
|
||||||
|
|
||||||
@@ -63,7 +47,7 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
|
|||||||
begidx = readline.get_begidx()
|
begidx = readline.get_begidx()
|
||||||
# Completing the first token (command).
|
# Completing the first token (command).
|
||||||
if begidx == 0 or (begidx > 0 and buffer[:begidx].strip() == ""):
|
if begidx == 0 or (begidx > 0 and buffer[:begidx].strip() == ""):
|
||||||
options = filter_prefix(text, list(SLASH_COMMANDS))
|
options = filter_prefix(text, slash_commands())
|
||||||
else:
|
else:
|
||||||
head = buffer[:begidx].strip()
|
head = buffer[:begidx].strip()
|
||||||
command = head.split()[0] if head else ""
|
command = head.split()[0] if head else ""
|
||||||
|
|||||||
+2
-454
@@ -1,467 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from msgspec import UNSET
|
|
||||||
|
|
||||||
from plyngent.cli.readline_setup import setup_readline
|
from plyngent.cli.readline_setup import setup_readline
|
||||||
from plyngent.cli.retry import retry_pending_with_retries, run_user_text_with_retries
|
from plyngent.cli.retry import run_user_text_with_retries
|
||||||
from plyngent.cli.selection import select_model, select_provider
|
from plyngent.cli.slash import handle_slash
|
||||||
from plyngent.lmproto.openai_compatible.model import (
|
|
||||||
AssistantChatMessage,
|
|
||||||
AssistantFunctionToolCall,
|
|
||||||
ToolChatMessage,
|
|
||||||
UserChatMessage,
|
|
||||||
)
|
|
||||||
from plyngent.runtime import ProviderNotSupportedError
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from plyngent.cli.state import ReplState
|
from plyngent.cli.state import ReplState
|
||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
|
||||||
|
|
||||||
HELP_TEXT = """\
|
|
||||||
Commands:
|
|
||||||
/help Show this help
|
|
||||||
/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 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 <name> 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
|
|
||||||
/tools [on|off] Show or toggle tools
|
|
||||||
/stream [on|off] Show or toggle streaming model output
|
|
||||||
/verbose [on|off] Show or toggle full tool-result dumps
|
|
||||||
/rounds [n] Show or set max tool-loop rounds
|
|
||||||
/retry Re-run incomplete last user turn (DB/orphan user; no retype)
|
|
||||||
/status Show session/provider/tools/rounds status
|
|
||||||
|
|
||||||
User messages are saved immediately. On API errors or Ctrl+C, partial
|
|
||||||
assistant/tool output is discarded but the user message stays (so /retry
|
|
||||||
works after resume, not only via readline history). Auto-retry: 10s/20s/30s.
|
|
||||||
|
|
||||||
Tab completes slash commands and some arguments (provider, model, tools,
|
|
||||||
stream, verbose). Use --session ID or /resume to continue a prior chat
|
|
||||||
after restart.
|
|
||||||
"""
|
|
||||||
|
|
||||||
type SlashHandler = Callable[[], None | Awaitable[None]]
|
|
||||||
|
|
||||||
_DEFAULT_HISTORY_LINES = 20
|
|
||||||
_CONTENT_PREVIEW = 200
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_status(state: ReplState) -> None:
|
|
||||||
from plyngent.agent.budget import estimate_messages_chars
|
|
||||||
|
|
||||||
pending = state.agent.pending_retry_text
|
|
||||||
pending_disp = "yes" if pending else "no"
|
|
||||||
ctx_chars = estimate_messages_chars(state.agent.messages)
|
|
||||||
ctx_tokens = state.agent.context_tokens
|
|
||||||
ctx_src = state.agent.context_tokens_source
|
|
||||||
ctx_budget = state.agent.max_context_tokens
|
|
||||||
session_u = state.agent.session_usage
|
|
||||||
last_u = state.agent.last_turn_usage
|
|
||||||
last_req = state.agent.last_request_usage
|
|
||||||
last_rounds = state.agent.last_turn_rounds
|
|
||||||
# API prompt_tokens from the last model call is real context size for that request.
|
|
||||||
ctx_tag = "api" if ctx_src == "api" else "est"
|
|
||||||
ctx_tilde = "" if ctx_src == "api" else "~"
|
|
||||||
click.echo(
|
|
||||||
f"provider={state.provider_name} model={state.model}\n"
|
|
||||||
f"session={state.session_id} messages={len(state.agent.messages)} "
|
|
||||||
f"pending_retry={pending_disp}\n"
|
|
||||||
f"tools={'on' if state.tools_enabled else 'off'} "
|
|
||||||
f"rounds={state.max_rounds} "
|
|
||||||
f"stream={'on' if state.agent.stream else 'off'} "
|
|
||||||
f"verbose={'on' if state.verbose else 'off'}\n"
|
|
||||||
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
|
|
||||||
f"context_chars={ctx_chars} "
|
|
||||||
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
|
||||||
f"last_request={last_req.format_line()}\n"
|
|
||||||
f"usage_last_turn={last_u.format_line(billed=True)} "
|
|
||||||
f"rounds={last_rounds}\n"
|
|
||||||
f"usage_session={session_u.format_line(billed=True)}\n"
|
|
||||||
f"workspace={state.workspace}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_sessions(state: ReplState) -> None:
|
|
||||||
sessions = await state.memory.list_sessions(workspace=state.workspace)
|
|
||||||
if not sessions:
|
|
||||||
click.echo(f"(no sessions for workspace {state.workspace})")
|
|
||||||
return
|
|
||||||
for session in sessions:
|
|
||||||
marker = "*" if session.sid == state.session_id else " "
|
|
||||||
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:
|
|
||||||
name = arg.strip() or "chat"
|
|
||||||
await state.new_session(name=name)
|
|
||||||
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 <name>")
|
|
||||||
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()
|
|
||||||
if mode == "new":
|
|
||||||
click.echo(f"no prior session; created new session {state.session_id}")
|
|
||||||
else:
|
|
||||||
click.echo(
|
|
||||||
f"resumed latest session {state.session_id} "
|
|
||||||
f"({len(state.agent.messages)} messages) workspace={state.workspace}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
session_id = int(arg.strip())
|
|
||||||
except ValueError:
|
|
||||||
click.echo("session id must be an integer (or omit for latest)")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await state.resume_session(session_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
click.echo(f"error: {exc}")
|
|
||||||
return
|
|
||||||
click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages) workspace={state.workspace}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_compact(state: ReplState, arg: str) -> None:
|
|
||||||
name = arg.strip() or None
|
|
||||||
click.secho("compacting (soft-compact + model summary)…", fg="yellow")
|
|
||||||
try:
|
|
||||||
old_id, new_id, summary = await state.compact_to_new_session(name=name)
|
|
||||||
except ValueError as exc:
|
|
||||||
click.echo(f"error: {exc}")
|
|
||||||
return
|
|
||||||
except Exception as exc: # noqa: BLE001 — surface model/API failures
|
|
||||||
click.secho(f"error: compact failed: {exc}", fg="red")
|
|
||||||
return
|
|
||||||
preview = summary if len(summary) <= 400 else summary[:400] + "…" # noqa: PLR2004
|
|
||||||
click.echo(f"compacted session {old_id} -> new session {new_id}")
|
|
||||||
click.secho(preview, fg="bright_black")
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_provider(state: ReplState, arg: str) -> None:
|
|
||||||
if not arg.strip():
|
|
||||||
click.echo(f"provider={state.provider_name}")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
name, provider = select_provider(state.config.providers, preferred=arg.strip())
|
|
||||||
state.provider_name = name
|
|
||||||
state.provider = provider
|
|
||||||
state.rebuild_client()
|
|
||||||
click.echo(f"switched provider to {name}")
|
|
||||||
except (click.ClickException, ProviderNotSupportedError) as exc:
|
|
||||||
click.echo(f"error: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_model(state: ReplState, arg: str) -> None:
|
|
||||||
if not arg.strip():
|
|
||||||
click.echo(f"model={state.model}")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
state.model = select_model(state.provider, preferred=arg.strip())
|
|
||||||
state.rebuild_client()
|
|
||||||
click.echo(f"switched model to {state.model}")
|
|
||||||
except click.ClickException as exc:
|
|
||||||
click.echo(f"error: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_tools(state: ReplState, arg: str) -> None:
|
|
||||||
token = arg.strip().lower()
|
|
||||||
if not token:
|
|
||||||
click.echo(f"tools={'on' if state.tools_enabled else 'off'}")
|
|
||||||
return
|
|
||||||
if token in {"on", "1", "true", "yes"}:
|
|
||||||
state.tools_enabled = True
|
|
||||||
elif token in {"off", "0", "false", "no"}:
|
|
||||||
state.tools_enabled = False
|
|
||||||
else:
|
|
||||||
click.echo("usage: /tools [on|off]")
|
|
||||||
return
|
|
||||||
state.rebuild_client()
|
|
||||||
click.echo(f"tools={'on' if state.tools_enabled else 'off'}")
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_stream(state: ReplState, arg: str) -> None:
|
|
||||||
token = arg.strip().lower()
|
|
||||||
if not token:
|
|
||||||
click.echo(f"stream={'on' if state.agent.stream else 'off'}")
|
|
||||||
return
|
|
||||||
if token in {"on", "1", "true", "yes"}:
|
|
||||||
enabled = True
|
|
||||||
elif token in {"off", "0", "false", "no"}:
|
|
||||||
enabled = False
|
|
||||||
else:
|
|
||||||
click.echo("usage: /stream [on|off]")
|
|
||||||
return
|
|
||||||
state.stream_enabled = enabled
|
|
||||||
state.agent.stream = enabled
|
|
||||||
click.echo(f"stream={'on' if enabled else 'off'}")
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_verbose(state: ReplState, arg: str) -> None:
|
|
||||||
token = arg.strip().lower()
|
|
||||||
if not token:
|
|
||||||
click.echo(f"verbose={'on' if state.verbose else 'off'}")
|
|
||||||
return
|
|
||||||
if token in {"on", "1", "true", "yes"}:
|
|
||||||
enabled = True
|
|
||||||
elif token in {"off", "0", "false", "no"}:
|
|
||||||
enabled = False
|
|
||||||
else:
|
|
||||||
click.echo("usage: /verbose [on|off]")
|
|
||||||
return
|
|
||||||
state.verbose = enabled
|
|
||||||
state.sync_display_flags()
|
|
||||||
click.echo(f"verbose={'on' if enabled else 'off'}")
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_rounds(state: ReplState, arg: str) -> None:
|
|
||||||
token = arg.strip()
|
|
||||||
if not token:
|
|
||||||
click.echo(f"max_rounds={state.max_rounds}")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
value = int(token)
|
|
||||||
except ValueError:
|
|
||||||
click.echo("usage: /rounds <positive integer>")
|
|
||||||
return
|
|
||||||
if value < 1:
|
|
||||||
click.echo("max_rounds must be >= 1")
|
|
||||||
return
|
|
||||||
state.max_rounds = value
|
|
||||||
state.agent.max_rounds = value
|
|
||||||
click.echo(f"max_rounds={state.max_rounds}")
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_clear(state: ReplState) -> None:
|
|
||||||
state.agent.messages.clear()
|
|
||||||
click.echo("conversation cleared (in-memory only; DB history kept)")
|
|
||||||
|
|
||||||
|
|
||||||
def _preview_content(text: str | None) -> str:
|
|
||||||
if not text:
|
|
||||||
return ""
|
|
||||||
if len(text) <= _CONTENT_PREVIEW:
|
|
||||||
return text
|
|
||||||
return text[:_CONTENT_PREVIEW] + "…"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
|
||||||
if isinstance(message, UserChatMessage):
|
|
||||||
return f"{index}. user: {_preview_content(message.content)}"
|
|
||||||
if isinstance(message, AssistantChatMessage):
|
|
||||||
parts: list[str] = []
|
|
||||||
if isinstance(message.content, str) and message.content:
|
|
||||||
parts.append(_preview_content(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)}]")
|
|
||||||
body = " ".join(parts) if parts else "(empty)"
|
|
||||||
return f"{index}. assistant: {body}"
|
|
||||||
if isinstance(message, ToolChatMessage):
|
|
||||||
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
|
|
||||||
role = getattr(message, "role", type(message).__name__)
|
|
||||||
content = getattr(message, "content", "")
|
|
||||||
return f"{index}. {role}: {_preview_content(str(content))}"
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_history(state: ReplState, arg: str) -> None:
|
|
||||||
token = arg.strip()
|
|
||||||
limit = _DEFAULT_HISTORY_LINES
|
|
||||||
if token:
|
|
||||||
try:
|
|
||||||
limit = int(token)
|
|
||||||
except ValueError:
|
|
||||||
click.echo("usage: /history [n]")
|
|
||||||
return
|
|
||||||
if limit < 1:
|
|
||||||
click.echo("n must be >= 1")
|
|
||||||
return
|
|
||||||
messages = state.agent.messages
|
|
||||||
if not messages:
|
|
||||||
click.echo("(no messages in this session)")
|
|
||||||
return
|
|
||||||
start = max(0, len(messages) - limit)
|
|
||||||
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}")
|
|
||||||
for offset, message in enumerate(messages[start:]):
|
|
||||||
click.echo(_format_history_message(start + offset, message))
|
|
||||||
if state.agent.pending_retry_text is not None:
|
|
||||||
click.secho(
|
|
||||||
f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}",
|
|
||||||
fg="yellow",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_retry(state: ReplState) -> None:
|
|
||||||
_ = await retry_pending_with_retries(state.agent)
|
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch_slash(state: ReplState, command: str, arg: str) -> bool:
|
|
||||||
if command in {"quit", "exit", "q"}:
|
|
||||||
return False
|
|
||||||
|
|
||||||
handlers: dict[str, SlashHandler] = {
|
|
||||||
"help": lambda: click.echo(HELP_TEXT),
|
|
||||||
"clear": lambda: _cmd_clear(state),
|
|
||||||
"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),
|
|
||||||
"model": lambda: _cmd_model(state, arg),
|
|
||||||
"tools": lambda: _cmd_tools(state, arg),
|
|
||||||
"stream": lambda: _cmd_stream(state, arg),
|
|
||||||
"verbose": lambda: _cmd_verbose(state, arg),
|
|
||||||
"rounds": lambda: _cmd_rounds(state, arg),
|
|
||||||
"retry": lambda: _cmd_retry(state),
|
|
||||||
"status": lambda: _cmd_status(state),
|
|
||||||
}
|
|
||||||
handler = handlers.get(command)
|
|
||||||
if handler is None:
|
|
||||||
click.echo(f"unknown command /{command}; try /help")
|
|
||||||
return True
|
|
||||||
result = handler()
|
|
||||||
if inspect.isawaitable(result):
|
|
||||||
await result
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_slash(state: ReplState, line: str) -> bool:
|
|
||||||
"""Handle a slash command. Returns False if the REPL should exit."""
|
|
||||||
body = line[1:].strip()
|
|
||||||
if not body:
|
|
||||||
return True
|
|
||||||
command, _, rest = body.partition(" ")
|
|
||||||
return await _dispatch_slash(state, command.lower(), rest.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _read_line() -> str:
|
def _read_line() -> str:
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
from typing import TYPE_CHECKING, Any, override
|
||||||
|
|
||||||
|
import awaitlet # pyright: ignore[reportMissingTypeStubs]
|
||||||
|
import click
|
||||||
|
from msgspec import UNSET
|
||||||
|
|
||||||
|
from plyngent.cli.retry import retry_pending_with_retries
|
||||||
|
from plyngent.cli.selection import select_model, select_provider
|
||||||
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AssistantChatMessage,
|
||||||
|
AssistantFunctionToolCall,
|
||||||
|
ToolChatMessage,
|
||||||
|
UserChatMessage,
|
||||||
|
)
|
||||||
|
from plyngent.runtime import ProviderNotSupportedError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Sequence
|
||||||
|
|
||||||
|
from plyngent.cli.state import ReplState
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
|
_DEFAULT_HISTORY_LINES = 20
|
||||||
|
_CONTENT_PREVIEW = 200
|
||||||
|
_COMPACT_PREVIEW = 400
|
||||||
|
|
||||||
|
HELP_FOOTER = """\
|
||||||
|
User messages are saved immediately. On API errors or Ctrl+C, partial
|
||||||
|
assistant/tool output is discarded but the user message stays (so /retry
|
||||||
|
works after resume, not only via readline history). Auto-retry: 10s/20s/30s.
|
||||||
|
|
||||||
|
Tab completes slash commands and some arguments (provider, model, tools,
|
||||||
|
stream, verbose, export). Use --session ID or /resume to continue a prior
|
||||||
|
chat after restart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ReplExitError(Exception):
|
||||||
|
"""Signal that the REPL should leave (not a process exit)."""
|
||||||
|
|
||||||
|
|
||||||
|
class OnOffParam(click.ParamType[bool]):
|
||||||
|
"""Accept on/off (and common synonyms); convert to bool."""
|
||||||
|
|
||||||
|
name: str = "on_off"
|
||||||
|
|
||||||
|
@override
|
||||||
|
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
token = str(value).strip().lower()
|
||||||
|
if token in {"on", "1", "true", "yes"}:
|
||||||
|
return True
|
||||||
|
if token in {"off", "0", "false", "no"}:
|
||||||
|
return False
|
||||||
|
self.fail("expected on or off", param, ctx)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
ON_OFF = OnOffParam()
|
||||||
|
|
||||||
|
|
||||||
|
class SlashGroup(click.Group):
|
||||||
|
"""Click group for REPL slash commands (no process-level ownership)."""
|
||||||
|
|
||||||
|
@override
|
||||||
|
def get_help(self, ctx: click.Context) -> str:
|
||||||
|
lines = ["Commands:"]
|
||||||
|
for name in sorted(self.list_commands(ctx)):
|
||||||
|
cmd = self.get_command(ctx, name)
|
||||||
|
if cmd is None or cmd.hidden:
|
||||||
|
continue
|
||||||
|
brief = cmd.get_short_help_str(limit=60)
|
||||||
|
lines.append(f" /{name:<16} {brief}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(HELP_FOOTER.rstrip())
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
slash = SlashGroup(
|
||||||
|
"slash",
|
||||||
|
help="REPL slash commands",
|
||||||
|
context_settings={"help_option_names": [], "max_content_width": 100},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def slash_command_names() -> list[str]:
|
||||||
|
"""Slash tokens for Tab completion (including leading /)."""
|
||||||
|
ctx = click.Context(slash)
|
||||||
|
return sorted(f"/{name}" for name in slash.list_commands(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
def _await[T](awaitable: Awaitable[T]) -> T:
|
||||||
|
# Greenlet parks until the awaitable completes on the running loop.
|
||||||
|
return awaitlet.awaitlet(awaitable) # pyright: ignore[reportUnknownVariableType]
|
||||||
|
|
||||||
|
|
||||||
|
# --- commands -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("help")
|
||||||
|
@click.argument("command", required=False)
|
||||||
|
@click.pass_context
|
||||||
|
def help_cmd(ctx: click.Context, command: str | None) -> None:
|
||||||
|
"""Show this help, or help for one command."""
|
||||||
|
if command:
|
||||||
|
name = command.lstrip("/").lower()
|
||||||
|
cmd = slash.get_command(ctx, name)
|
||||||
|
if cmd is None:
|
||||||
|
click.echo(f"unknown command /{name}; try /help")
|
||||||
|
return
|
||||||
|
# Build a child context so get_help includes usage.
|
||||||
|
with click.Context(cmd, info_name=name, parent=ctx) as sub:
|
||||||
|
click.echo(cmd.get_help(sub))
|
||||||
|
return
|
||||||
|
click.echo(slash.get_help(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("quit")
|
||||||
|
@click.pass_obj
|
||||||
|
def quit_cmd(_state: ReplState) -> None:
|
||||||
|
"""Leave the REPL."""
|
||||||
|
raise ReplExitError
|
||||||
|
|
||||||
|
|
||||||
|
slash.add_command(quit_cmd, name="exit")
|
||||||
|
slash.add_command(quit_cmd, name="q")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("clear")
|
||||||
|
@click.pass_obj
|
||||||
|
def clear_cmd(state: ReplState) -> None:
|
||||||
|
"""Clear in-memory conversation (keeps session id)."""
|
||||||
|
state.agent.messages.clear()
|
||||||
|
click.echo("conversation cleared (in-memory only; DB history kept)")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("status")
|
||||||
|
@click.pass_obj
|
||||||
|
def status_cmd(state: ReplState) -> None:
|
||||||
|
"""Show session/provider/tools/rounds status."""
|
||||||
|
from plyngent.agent.budget import estimate_messages_chars
|
||||||
|
|
||||||
|
pending = state.agent.pending_retry_text
|
||||||
|
pending_disp = "yes" if pending else "no"
|
||||||
|
ctx_chars = estimate_messages_chars(state.agent.messages)
|
||||||
|
ctx_tokens = state.agent.context_tokens
|
||||||
|
ctx_src = state.agent.context_tokens_source
|
||||||
|
ctx_budget = state.agent.max_context_tokens
|
||||||
|
session_u = state.agent.session_usage
|
||||||
|
last_u = state.agent.last_turn_usage
|
||||||
|
last_req = state.agent.last_request_usage
|
||||||
|
last_rounds = state.agent.last_turn_rounds
|
||||||
|
ctx_tag = "api" if ctx_src == "api" else "est"
|
||||||
|
ctx_tilde = "" if ctx_src == "api" else "~"
|
||||||
|
click.echo(
|
||||||
|
f"provider={state.provider_name} model={state.model}\n"
|
||||||
|
f"session={state.session_id} messages={len(state.agent.messages)} "
|
||||||
|
f"pending_retry={pending_disp}\n"
|
||||||
|
f"tools={'on' if state.tools_enabled else 'off'} "
|
||||||
|
f"rounds={state.max_rounds} "
|
||||||
|
f"stream={'on' if state.agent.stream else 'off'} "
|
||||||
|
f"verbose={'on' if state.verbose else 'off'}\n"
|
||||||
|
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
|
||||||
|
f"context_chars={ctx_chars} "
|
||||||
|
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
||||||
|
f"last_request={last_req.format_line()}\n"
|
||||||
|
f"usage_last_turn={last_u.format_line(billed=True)} "
|
||||||
|
f"rounds={last_rounds}\n"
|
||||||
|
f"usage_session={session_u.format_line(billed=True)}\n"
|
||||||
|
f"workspace={state.workspace}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("sessions")
|
||||||
|
@click.pass_obj
|
||||||
|
def sessions_cmd(state: ReplState) -> None:
|
||||||
|
"""List sessions for this workspace (newest first)."""
|
||||||
|
sessions = _await(state.memory.list_sessions(workspace=state.workspace))
|
||||||
|
if not sessions:
|
||||||
|
click.echo(f"(no sessions for workspace {state.workspace})")
|
||||||
|
return
|
||||||
|
for session in sessions:
|
||||||
|
marker = "*" if session.sid == state.session_id else " "
|
||||||
|
ws = session.workspace or "(unbound)"
|
||||||
|
click.echo(f"{marker} {session.sid}\t{session.name}\tworkspace={ws}\tupdated={session.updated_at}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("new")
|
||||||
|
@click.argument("name", required=False, default="chat")
|
||||||
|
@click.pass_obj
|
||||||
|
def new_cmd(state: ReplState, name: str) -> None:
|
||||||
|
"""Start a new session (bound to workspace)."""
|
||||||
|
label = name.strip() or "chat"
|
||||||
|
_await(state.new_session(name=label))
|
||||||
|
click.echo(f"new session id={state.session_id} name={label}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("rename")
|
||||||
|
@click.argument("name", nargs=-1, required=True)
|
||||||
|
@click.pass_obj
|
||||||
|
def rename_cmd(state: ReplState, name: tuple[str, ...]) -> None:
|
||||||
|
"""Rename the current session."""
|
||||||
|
full = " ".join(name).strip()
|
||||||
|
if not full:
|
||||||
|
msg = "NAME is required"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
try:
|
||||||
|
row = _await(state.rename_current_session(full))
|
||||||
|
except ValueError as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
return
|
||||||
|
click.echo(f"renamed session {row.sid} -> {row.name}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("delete")
|
||||||
|
@click.argument("session_id", type=int, required=False)
|
||||||
|
@click.pass_obj
|
||||||
|
def delete_cmd(state: ReplState, session_id: int | None) -> None:
|
||||||
|
"""Hard-delete a session (confirm; current → new empty)."""
|
||||||
|
from plyngent.prompting import NonInteractiveError, confirm_async
|
||||||
|
|
||||||
|
sid = session_id
|
||||||
|
if sid is None:
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("export")
|
||||||
|
@click.argument("parts", nargs=-1)
|
||||||
|
@click.pass_obj
|
||||||
|
def export_cmd(state: ReplState, parts: tuple[str, ...]) -> None:
|
||||||
|
"""Export session transcript from DB: /export [md|json] [path]."""
|
||||||
|
from plyngent.cli.export import (
|
||||||
|
encode_session_export_json,
|
||||||
|
format_session_export_md,
|
||||||
|
resolve_export_path,
|
||||||
|
session_export_payload,
|
||||||
|
write_export_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt = "md"
|
||||||
|
path: str | None = None
|
||||||
|
if parts:
|
||||||
|
first = parts[0].lower()
|
||||||
|
if first in {"md", "markdown", "json"}:
|
||||||
|
fmt = "json" if first == "json" else "md"
|
||||||
|
path = parts[1] if len(parts) > 1 else None
|
||||||
|
if len(parts) > 2: # noqa: PLR2004
|
||||||
|
msg = "usage: /export [md|json] [path]"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
else:
|
||||||
|
path = parts[0]
|
||||||
|
if len(parts) > 1:
|
||||||
|
msg = "usage: /export [md|json] [path]"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
|
||||||
|
if state.session_id is None:
|
||||||
|
click.echo("error: no active session")
|
||||||
|
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)
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("resume")
|
||||||
|
@click.argument("session_id", type=int, required=False)
|
||||||
|
@click.pass_obj
|
||||||
|
def resume_cmd(state: ReplState, session_id: int | None) -> None:
|
||||||
|
"""Resume session id, or latest for this workspace if omitted."""
|
||||||
|
if session_id is None:
|
||||||
|
mode = _await(state.resume_latest_or_new())
|
||||||
|
if mode == "new":
|
||||||
|
click.echo(f"no prior session; created new session {state.session_id}")
|
||||||
|
else:
|
||||||
|
click.echo(
|
||||||
|
f"resumed latest session {state.session_id} "
|
||||||
|
f"({len(state.agent.messages)} messages) workspace={state.workspace}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_await(state.resume_session(session_id))
|
||||||
|
except ValueError as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
return
|
||||||
|
click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages) workspace={state.workspace}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("compact")
|
||||||
|
@click.argument("name", required=False)
|
||||||
|
@click.pass_obj
|
||||||
|
def compact_cmd(state: ReplState, name: str | None) -> None:
|
||||||
|
"""Soft-compact + model-summarize into a new session."""
|
||||||
|
click.secho("compacting (soft-compact + model summary)…", fg="yellow")
|
||||||
|
try:
|
||||||
|
old_id, new_id, summary = _await(state.compact_to_new_session(name=name))
|
||||||
|
except ValueError as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface model/API failures
|
||||||
|
click.secho(f"error: compact failed: {exc}", fg="red")
|
||||||
|
return
|
||||||
|
preview = summary if len(summary) <= _COMPACT_PREVIEW else summary[:_COMPACT_PREVIEW] + "…"
|
||||||
|
click.echo(f"compacted session {old_id} -> new session {new_id}")
|
||||||
|
click.secho(preview, fg="bright_black")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("provider")
|
||||||
|
@click.argument("name", required=False)
|
||||||
|
@click.pass_obj
|
||||||
|
def provider_cmd(state: ReplState, name: str | None) -> None:
|
||||||
|
"""Show or switch provider."""
|
||||||
|
if not name:
|
||||||
|
click.echo(f"provider={state.provider_name}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pname, provider = select_provider(state.config.providers, preferred=name.strip())
|
||||||
|
state.provider_name = pname
|
||||||
|
state.provider = provider
|
||||||
|
state.rebuild_client()
|
||||||
|
click.echo(f"switched provider to {pname}")
|
||||||
|
except (click.ClickException, ProviderNotSupportedError) as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("model")
|
||||||
|
@click.argument("model_id", required=False)
|
||||||
|
@click.pass_obj
|
||||||
|
def model_cmd(state: ReplState, model_id: str | None) -> None:
|
||||||
|
"""Show or switch model."""
|
||||||
|
if not model_id:
|
||||||
|
click.echo(f"model={state.model}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
state.model = select_model(state.provider, preferred=model_id.strip())
|
||||||
|
state.rebuild_client()
|
||||||
|
click.echo(f"switched model to {state.model}")
|
||||||
|
except click.ClickException as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("tools")
|
||||||
|
@click.argument("enabled", required=False, type=ON_OFF)
|
||||||
|
@click.pass_obj
|
||||||
|
def tools_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
|
||||||
|
"""Show or toggle tools."""
|
||||||
|
if enabled is None:
|
||||||
|
click.echo(f"tools={'on' if state.tools_enabled else 'off'}")
|
||||||
|
return
|
||||||
|
state.tools_enabled = enabled
|
||||||
|
state.rebuild_client()
|
||||||
|
click.echo(f"tools={'on' if enabled else 'off'}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("stream")
|
||||||
|
@click.argument("enabled", required=False, type=ON_OFF)
|
||||||
|
@click.pass_obj
|
||||||
|
def stream_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
|
||||||
|
"""Show or toggle streaming model output."""
|
||||||
|
if enabled is None:
|
||||||
|
click.echo(f"stream={'on' if state.agent.stream else 'off'}")
|
||||||
|
return
|
||||||
|
state.stream_enabled = enabled
|
||||||
|
state.agent.stream = enabled
|
||||||
|
click.echo(f"stream={'on' if enabled else 'off'}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("verbose")
|
||||||
|
@click.argument("enabled", required=False, type=ON_OFF)
|
||||||
|
@click.pass_obj
|
||||||
|
def verbose_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
|
||||||
|
"""Show or toggle full tool-result dumps."""
|
||||||
|
if enabled is None:
|
||||||
|
click.echo(f"verbose={'on' if state.verbose else 'off'}")
|
||||||
|
return
|
||||||
|
state.verbose = enabled
|
||||||
|
state.sync_display_flags()
|
||||||
|
click.echo(f"verbose={'on' if enabled else 'off'}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("rounds")
|
||||||
|
@click.argument("n", required=False, type=int)
|
||||||
|
@click.pass_obj
|
||||||
|
def rounds_cmd(state: ReplState, n: int | None) -> None:
|
||||||
|
"""Show or set max tool-loop rounds."""
|
||||||
|
if n is None:
|
||||||
|
click.echo(f"max_rounds={state.max_rounds}")
|
||||||
|
return
|
||||||
|
if n < 1:
|
||||||
|
msg = "max_rounds must be >= 1"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
state.max_rounds = n
|
||||||
|
state.agent.max_rounds = n
|
||||||
|
click.echo(f"max_rounds={state.max_rounds}")
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("history")
|
||||||
|
@click.argument("n", required=False, type=int)
|
||||||
|
@click.pass_obj
|
||||||
|
def history_cmd(state: ReplState, n: int | None) -> None:
|
||||||
|
"""Show last n messages in this session (default 20)."""
|
||||||
|
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
||||||
|
if limit < 1:
|
||||||
|
msg = "n must be >= 1"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
messages = state.agent.messages
|
||||||
|
if not messages:
|
||||||
|
click.echo("(no messages in this session)")
|
||||||
|
return
|
||||||
|
start = max(0, len(messages) - limit)
|
||||||
|
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}")
|
||||||
|
for offset, message in enumerate(messages[start:]):
|
||||||
|
click.echo(_format_history_message(start + offset, message))
|
||||||
|
if state.agent.pending_retry_text is not None:
|
||||||
|
click.secho(
|
||||||
|
f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}",
|
||||||
|
fg="yellow",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("retry")
|
||||||
|
@click.pass_obj
|
||||||
|
def retry_cmd(state: ReplState) -> None:
|
||||||
|
"""Re-run incomplete last user turn (DB/orphan user; no retype)."""
|
||||||
|
_ = _await(retry_pending_with_retries(state.agent))
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_content(text: str | None) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if len(text) <= _CONTENT_PREVIEW:
|
||||||
|
return text
|
||||||
|
return text[:_CONTENT_PREVIEW] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
||||||
|
if isinstance(message, UserChatMessage):
|
||||||
|
return f"{index}. user: {_preview_content(message.content)}"
|
||||||
|
if isinstance(message, AssistantChatMessage):
|
||||||
|
parts: list[str] = []
|
||||||
|
if isinstance(message.content, str) and message.content:
|
||||||
|
parts.append(_preview_content(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)}]")
|
||||||
|
body = " ".join(parts) if parts else "(empty)"
|
||||||
|
return f"{index}. assistant: {body}"
|
||||||
|
if isinstance(message, ToolChatMessage):
|
||||||
|
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
|
||||||
|
role = getattr(message, "role", type(message).__name__)
|
||||||
|
content = getattr(message, "content", "")
|
||||||
|
return f"{index}. {role}: {_preview_content(str(content))}"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
|
||||||
|
"""Sync Click entrypoint; may call awaitlet() for async work."""
|
||||||
|
# standalone_mode=False → UsageError/ClickException instead of SystemExit.
|
||||||
|
slash.main(
|
||||||
|
args=list(args),
|
||||||
|
prog_name="",
|
||||||
|
obj=state,
|
||||||
|
standalone_mode=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_slash(state: ReplState, line: str) -> bool:
|
||||||
|
"""Handle a slash command. Returns False if the REPL should exit."""
|
||||||
|
body = line[1:].strip()
|
||||||
|
if not body:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
args = shlex.split(body)
|
||||||
|
except ValueError as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
return True
|
||||||
|
if not args:
|
||||||
|
return True
|
||||||
|
args[0] = args[0].lower()
|
||||||
|
try:
|
||||||
|
await awaitlet.async_def(_run_slash_argv, args, state)
|
||||||
|
except ReplExitError:
|
||||||
|
return False
|
||||||
|
except click.ClickException as exc:
|
||||||
|
exc.show()
|
||||||
|
except click.exceptions.Exit as exc:
|
||||||
|
# Click may still raise Exit(0) for some paths; ignore non-error codes.
|
||||||
|
if exc.exit_code not in {0, None}:
|
||||||
|
click.echo(f"error: exit {exc.exit_code}")
|
||||||
|
except click.Abort:
|
||||||
|
click.echo("aborted")
|
||||||
|
return True
|
||||||
@@ -3,10 +3,10 @@ from __future__ import annotations
|
|||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from plyngent.cli.readline_setup import (
|
from plyngent.cli.readline_setup import (
|
||||||
SLASH_COMMANDS,
|
|
||||||
build_completer,
|
build_completer,
|
||||||
filter_prefix,
|
filter_prefix,
|
||||||
history_path,
|
history_path,
|
||||||
|
slash_commands,
|
||||||
)
|
)
|
||||||
from plyngent.cli.state import ReplState
|
from plyngent.cli.state import ReplState
|
||||||
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
|
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
|
||||||
@@ -76,10 +76,11 @@ def test_completer_commands(tmp_path: object, monkeypatch: object) -> None:
|
|||||||
break
|
break
|
||||||
found.append(item)
|
found.append(item)
|
||||||
index += 1
|
index += 1
|
||||||
|
names = slash_commands()
|
||||||
assert "/help" in found
|
assert "/help" in found
|
||||||
assert "/stream" in SLASH_COMMANDS
|
assert "/stream" in names
|
||||||
assert "/verbose" in SLASH_COMMANDS
|
assert "/verbose" in names
|
||||||
assert set(found) <= set(SLASH_COMMANDS)
|
assert set(found) <= set(names)
|
||||||
|
|
||||||
|
|
||||||
def test_bind_tab_complete_runs() -> None:
|
def test_bind_tab_complete_runs() -> None:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from plyngent.agent import ChatAgent
|
from plyngent.agent import ChatAgent
|
||||||
from plyngent.cli.repl import handle_slash
|
from plyngent.cli.slash import handle_slash
|
||||||
from plyngent.cli.state import ReplState
|
from plyngent.cli.state import ReplState
|
||||||
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||||
from plyngent.config.store import ConfigStore
|
from plyngent.config.store import ConfigStore
|
||||||
|
|||||||
Reference in New Issue
Block a user