mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
cli/history: group /history by turn
/history counted raw message rows, so /history 1 printed whatever row happened to be last (usually a tool result in a tool-heavy session) and "one row means full body" was a proxy for "show me that one thing". A turn is what a human reviews: /history now prints the last turn's two ends (the user message and the model's answer, markdown for the latter), -v prints every row of the selected turn(s), -vv prints full bodies plus the local system row, and --preview collapses every row to one line. N addresses turn N, last [N] the last N turns, --message N a single row. Numbering is a display ordinal over conversation rows (local-only rows print as local) and deliberately never a database row number - seq is an implementation detail that shifts with /clear. Grouping and rendering live in the new cli/transcript.py.
This commit is contained in:
+98
-161
@@ -2,23 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, Any, cast, override
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, override
|
||||
|
||||
import awaitlet # pyright: ignore[reportMissingTypeStubs]
|
||||
import click
|
||||
from click.shell_completion import CompletionItem
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.cli.models_source import DEFAULT_MODELS_CACHE_TTL, model_choices_for_provider
|
||||
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,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
from plyngent.runtime import ProviderNotSupportedError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -26,10 +18,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from plyngent.agent import ToolRegistry
|
||||
from plyngent.cli.state import ReplState, YoloMode
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
_DEFAULT_HISTORY_LINES = 20
|
||||
_CONTENT_PREVIEW = 200
|
||||
_COMPACT_PREVIEW = 400
|
||||
_ON_OFF_CHOICES = ("on", "off")
|
||||
_YOLO_MODE_CHOICES = ("on", "off", "once")
|
||||
@@ -49,33 +38,77 @@ _TODO_ACTION_CHOICES = (
|
||||
_ROUNDS_CHOICES = ("8", "16", "32", "64", "128")
|
||||
|
||||
|
||||
class HistoryLimitType(click.ParamType[int]):
|
||||
"""``N`` (int >= 1) or the shortcut ``last`` (equivalent to ``1``)."""
|
||||
class HistoryTargetType(click.ParamType[int | Literal["last"]]):
|
||||
"""``N`` (turn number >= 1) or the keyword ``last`` (most recent turn)."""
|
||||
|
||||
name: str = "history_limit"
|
||||
name: str = "history_target"
|
||||
|
||||
@override
|
||||
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||
if isinstance(value, int):
|
||||
if value < 1:
|
||||
self.fail("must be >= 1", param, ctx)
|
||||
return value
|
||||
text = str(value).strip().lower()
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> int | Literal["last"]:
|
||||
text = value.strip().lower() if isinstance(value, str) else str(value)
|
||||
if text == "last":
|
||||
return 1
|
||||
return "last"
|
||||
try:
|
||||
n = int(text, 10)
|
||||
except ValueError:
|
||||
self.fail("expected an integer N or 'last'", param, ctx)
|
||||
self.fail("expected a turn number N or 'last'", param, ctx)
|
||||
if n < 1:
|
||||
self.fail("must be >= 1", param, ctx)
|
||||
return n
|
||||
|
||||
@override
|
||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||
del ctx, param
|
||||
tokens = ("last", "1", "5", "10", "20")
|
||||
return [CompletionItem(token) for token in tokens if incomplete == "" or token.startswith(incomplete.lower())]
|
||||
del param
|
||||
tokens = ["last"]
|
||||
state = _repl_state(ctx)
|
||||
if state is not None:
|
||||
from plyngent.cli.transcript import build_transcript
|
||||
|
||||
turns = build_transcript(state.agent.messages).turns
|
||||
# Recent turn numbers are the ones a human is likely to jump back to.
|
||||
tokens += [str(turn.ordinal) for turn in turns[-5:]]
|
||||
token = incomplete.strip().lower()
|
||||
return [CompletionItem(item) for item in tokens if item.startswith(token)]
|
||||
|
||||
|
||||
HISTORY_TARGET = HistoryTargetType()
|
||||
|
||||
|
||||
class HistoryRowNumberType(click.ParamType[int]):
|
||||
"""One-based display number of a transcript row (``/history --message N``)."""
|
||||
|
||||
name: str = "history_message"
|
||||
|
||||
@override
|
||||
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||
try:
|
||||
n = int(str(value), 10)
|
||||
except ValueError:
|
||||
self.fail("expected a message number N", param, ctx)
|
||||
if n < 1:
|
||||
self.fail("must be >= 1", param, ctx)
|
||||
return n
|
||||
|
||||
@override
|
||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||
del param
|
||||
state = _repl_state(ctx)
|
||||
if state is None:
|
||||
return []
|
||||
from plyngent.cli.transcript import build_transcript
|
||||
|
||||
transcript = build_transcript(state.agent.messages)
|
||||
token = incomplete.strip()
|
||||
recent = range(max(1, transcript.messages - 4), transcript.messages + 1)
|
||||
return [CompletionItem(str(n)) for n in recent if str(n).startswith(token)]
|
||||
|
||||
|
||||
HISTORY_ROW_NUMBER = HistoryRowNumberType()
|
||||
|
||||
|
||||
HELP_FOOTER = (
|
||||
@@ -1367,65 +1400,61 @@ def rounds_cmd(state: ReplState, n: int | None) -> None:
|
||||
|
||||
|
||||
@slash.command("history")
|
||||
@click.argument("n", required=False, type=HistoryLimitType())
|
||||
@click.argument("target", required=False, type=HISTORY_TARGET)
|
||||
@click.argument("count", required=False, type=click.IntRange(min=1), metavar="[N]")
|
||||
@click.option(
|
||||
"--full",
|
||||
"mode_full",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print full message bodies (markdown for assistant when TTY).",
|
||||
"--message",
|
||||
"message_no",
|
||||
type=HISTORY_ROW_NUMBER,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Print message N (display number) instead of a turn.",
|
||||
)
|
||||
@click.option(
|
||||
"-v",
|
||||
"--verbose",
|
||||
"verbose",
|
||||
count=True,
|
||||
help="Print every row of the turn(s); repeat (-vv) for full bodies.",
|
||||
)
|
||||
@click.option(
|
||||
"--preview",
|
||||
"mode_preview",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Force short previews even for a single message.",
|
||||
help="One-line preview for every printed row.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def history_cmd(
|
||||
state: ReplState,
|
||||
n: int | None,
|
||||
target: int | Literal["last"] | None,
|
||||
count: int | None,
|
||||
*,
|
||||
mode_full: bool,
|
||||
message_no: int | None,
|
||||
verbose: int,
|
||||
mode_preview: bool,
|
||||
) -> None:
|
||||
"""Show recent messages (default: last 20 as short previews).
|
||||
"""Show conversation history, grouped by turn.
|
||||
|
||||
``/history last`` or ``/history 1`` shows the last message in full with Rich
|
||||
markdown when available. Use ``--full`` for multiple messages in full;
|
||||
``--preview`` forces the short form even for a single message.
|
||||
``/history`` — the last turn: your message and the model's final answer
|
||||
``/history N`` — turn N (numbered from the first turn of this session)
|
||||
``/history last [N]`` — the last N turns (default 1)
|
||||
``/history --message N`` — one message (display number, not a DB row)
|
||||
``-v`` adds every intermediate row (tool calls/results, notices); ``-vv``
|
||||
prints full bodies for all of them; ``--preview`` collapses to one line each.
|
||||
"""
|
||||
if mode_full and mode_preview:
|
||||
msg = "use only one of --full / --preview"
|
||||
raise click.UsageError(msg)
|
||||
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
||||
messages = state.agent.messages
|
||||
if not messages:
|
||||
click.echo("(no messages in this session)")
|
||||
return
|
||||
start = max(0, len(messages) - limit)
|
||||
slice_msgs = messages[start:]
|
||||
# Full: --full, or single-message window (last / 1) unless --preview.
|
||||
full = mode_full or (len(slice_msgs) == 1 and not mode_preview)
|
||||
mode_tag = "full" if full else "preview"
|
||||
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(slice_msgs)} mode={mode_tag}")
|
||||
for offset, message in enumerate(slice_msgs):
|
||||
idx = start + offset
|
||||
if full:
|
||||
_print_history_message_full(idx, message)
|
||||
else:
|
||||
click.echo(_format_history_message(idx, message))
|
||||
if state.agent.pending_retry_text is not None:
|
||||
pending = state.agent.pending_retry_text
|
||||
if full:
|
||||
click.secho("(pending retry) user:", fg="yellow")
|
||||
click.echo(pending)
|
||||
else:
|
||||
click.secho(
|
||||
f"(pending retry) user: {_preview_content(pending)}",
|
||||
fg="yellow",
|
||||
)
|
||||
from plyngent.cli import transcript as transcript_mod
|
||||
|
||||
transcript_mod.echo_history(
|
||||
state.agent.messages,
|
||||
session_id=state.session_id,
|
||||
pending_retry_text=state.agent.pending_retry_text,
|
||||
target=target,
|
||||
count=count,
|
||||
message_no=message_no,
|
||||
verbose=verbose,
|
||||
preview=mode_preview,
|
||||
)
|
||||
|
||||
|
||||
@slash.command("todos")
|
||||
@@ -1519,98 +1548,6 @@ def retry_cmd(state: ReplState) -> None:
|
||||
_ = _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 _print_history_assistant_full(index: int, message: AssistantChatMessage) -> None:
|
||||
from plyngent.cli.display import markdown_render_available, print_markdown
|
||||
|
||||
click.secho(f"{index}. assistant:", fg="cyan")
|
||||
content = message.content
|
||||
has_text = isinstance(content, str) and content.strip()
|
||||
if has_text and isinstance(content, str):
|
||||
if markdown_render_available():
|
||||
print_markdown(content, label="")
|
||||
else:
|
||||
click.echo(content)
|
||||
reasoning = message.reasoning_content
|
||||
has_reason = isinstance(reasoning, str) and reasoning.strip()
|
||||
if has_reason and isinstance(reasoning, str):
|
||||
click.secho("reasoning:", fg="bright_black")
|
||||
click.echo(reasoning)
|
||||
tool_calls = message.tool_calls
|
||||
has_tools = tool_calls is not UNSET and bool(tool_calls)
|
||||
if has_tools and tool_calls is not UNSET:
|
||||
for call in tool_calls:
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
click.secho(
|
||||
f" tool_call {call.id}: {call.function.name}({call.function.arguments})",
|
||||
fg="yellow",
|
||||
)
|
||||
else:
|
||||
click.secho(f" tool_call custom id={call.id}", fg="yellow")
|
||||
if not (has_text or has_reason or has_tools):
|
||||
click.echo("(empty)")
|
||||
click.echo()
|
||||
|
||||
|
||||
def _print_history_message_full(index: int, message: AnyChatMessage) -> None:
|
||||
"""Print one history message with full body; Rich markdown for assistant text."""
|
||||
if isinstance(message, UserChatMessage):
|
||||
click.secho(f"{index}. user:", fg="green")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
return
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
click.secho(f"{index}. developer:", fg="blue")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
return
|
||||
if isinstance(message, SystemChatMessage):
|
||||
click.secho(f"{index}. system:", fg="bright_black")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
return
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
_print_history_assistant_full(index, message)
|
||||
return
|
||||
# ToolChatMessage (remaining AnyChatMessage arm)
|
||||
click.secho(f"{index}. tool({message.tool_call_id}):", fg="magenta")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
|
||||
|
||||
def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
||||
if isinstance(message, UserChatMessage):
|
||||
return f"{index}. user: {_preview_content(message.content)}"
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
return f"{index}. developer: {_preview_content(message.content)}"
|
||||
if isinstance(message, SystemChatMessage):
|
||||
return f"{index}. system: {_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}"
|
||||
# ToolChatMessage
|
||||
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.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.
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Turn-oriented transcript grouping/rendering for ``/history``.
|
||||
|
||||
The REPL transcript is a flat list of chat messages, but what a human reviews is
|
||||
a *turn*: their message, the tool-loop rounds it triggered, and the model's final
|
||||
answer. This module groups the list into numbered rows and turns, and renders
|
||||
either the two ends of a turn (default), every row of it (``-v``), or full bodies
|
||||
for every printed row (``-vv``).
|
||||
|
||||
Numbering is a **display ordinal** over the conversation rows (1-based, in the
|
||||
order the session holds them): it is what ``/history`` prints and what
|
||||
``/history --message N`` addresses. Database row numbers are deliberately never
|
||||
shown or used — they are an implementation detail that shifts with ``/clear``.
|
||||
Local-only rows (the injected system prompt) have no number.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import click
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionToolCall,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
PREVIEW_CHARS = 200
|
||||
# ``-v`` expands a turn's rows; ``-vv`` also prints their full bodies.
|
||||
MAX_VERBOSE = 2
|
||||
|
||||
# Detail policy: ``mixed`` = a turn's final assistant answer in full, everything
|
||||
# else on one line; ``full``/``preview`` force the extremes.
|
||||
type Detail = Literal["mixed", "full", "preview"]
|
||||
# ``/history N`` addresses a turn by number; ``last`` counts from the end.
|
||||
type TurnTarget = int | Literal["last"]
|
||||
|
||||
|
||||
def _content(message: AnyChatMessage) -> str:
|
||||
"""Message body as text (``content`` may be UNSET/None for assistants)."""
|
||||
return message.content if isinstance(message.content, str) else ""
|
||||
|
||||
|
||||
def one_line(text: str | None, *, limit: int = PREVIEW_CHARS) -> str:
|
||||
"""Collapse *text* to one line: first line, truncated, may add ``(+N lines)``."""
|
||||
body = (text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
lines = body.splitlines()
|
||||
head = lines[0].strip()
|
||||
if len(head) > limit:
|
||||
head = head[:limit] + "…"
|
||||
if len(lines) > 1:
|
||||
head += f" (+{len(lines) - 1} lines)"
|
||||
return head
|
||||
|
||||
|
||||
def _role(message: AnyChatMessage) -> str:
|
||||
if isinstance(message, UserChatMessage):
|
||||
return "user"
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
return "assistant"
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
return "developer"
|
||||
if isinstance(message, SystemChatMessage):
|
||||
return "system"
|
||||
assert isinstance(message, ToolChatMessage)
|
||||
return f"tool({message.tool_call_id})"
|
||||
|
||||
|
||||
_ROLE_COLOR: dict[str, str] = {
|
||||
"user": "green",
|
||||
"assistant": "cyan",
|
||||
"developer": "blue",
|
||||
"system": "bright_black",
|
||||
}
|
||||
|
||||
|
||||
def _role_color(message: AnyChatMessage) -> str:
|
||||
return "magenta" if isinstance(message, ToolChatMessage) else _ROLE_COLOR[_role(message)]
|
||||
|
||||
|
||||
def _tool_call_names(message: AssistantChatMessage) -> list[str]:
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is UNSET or not tool_calls:
|
||||
return []
|
||||
return [call.function.name if isinstance(call, AssistantFunctionToolCall) else "custom" for call in tool_calls]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Row:
|
||||
"""One transcript row; ``number`` is the display ordinal (None = local-only)."""
|
||||
|
||||
message: AnyChatMessage
|
||||
number: int | None
|
||||
|
||||
def prefix(self) -> str:
|
||||
"""``12. user:`` — always styled (local rows keep the role colour)."""
|
||||
label = "local" if self.number is None else str(self.number)
|
||||
return str(click.style(f"{label}. {_role(self.message)}:", fg=_role_color(self.message)))
|
||||
|
||||
def preview(self) -> str:
|
||||
message = self.message
|
||||
names = _tool_call_names(message) if isinstance(message, AssistantChatMessage) else []
|
||||
suffix = f" tool_calls=[{', '.join(names)}]" if names else ""
|
||||
text = one_line(_content(message))
|
||||
if not text and isinstance(message, AssistantChatMessage) and not names:
|
||||
text = "(empty)"
|
||||
return f"{text}{suffix}".strip()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Turn:
|
||||
"""A user turn: the user row plus the rounds it triggered."""
|
||||
|
||||
ordinal: int
|
||||
rows: tuple[Row, ...]
|
||||
incomplete: bool = False
|
||||
|
||||
@property
|
||||
def user(self) -> Row:
|
||||
return self.rows[0]
|
||||
|
||||
@property
|
||||
def last(self) -> Row:
|
||||
return self.rows[-1]
|
||||
|
||||
@property
|
||||
def rounds(self) -> int:
|
||||
"""Model rounds that asked for tools (one tool batch each)."""
|
||||
return sum(
|
||||
1
|
||||
for row in self.rows
|
||||
if isinstance(row.message, AssistantChatMessage) and bool(_tool_call_names(row.message))
|
||||
)
|
||||
|
||||
def label(self) -> str:
|
||||
numbers = [row.number for row in self.rows if row.number is not None]
|
||||
parts = [f"msgs {numbers[0]}-{numbers[-1]}" if numbers else "local rows"]
|
||||
if self.rounds:
|
||||
parts.append(f"rounds={self.rounds}")
|
||||
text = f"turn {self.ordinal} ({', '.join(parts)})"
|
||||
if self.incomplete:
|
||||
text += " [incomplete — /retry continues]"
|
||||
return text
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Transcript:
|
||||
"""Numbered rows grouped by turn (plus rows that precede the first turn)."""
|
||||
|
||||
prelude: tuple[Row, ...]
|
||||
turns: tuple[Turn, ...]
|
||||
|
||||
@property
|
||||
def messages(self) -> int:
|
||||
"""Numbered rows (what ``/history --message N`` can address)."""
|
||||
return sum(1 for turn in self.turns for row in turn.rows if row.number is not None) + sum(
|
||||
1 for row in self.prelude if row.number is not None
|
||||
)
|
||||
|
||||
def turn(self, ordinal: int) -> Turn | None:
|
||||
return next((turn for turn in self.turns if turn.ordinal == ordinal), None)
|
||||
|
||||
def row(self, number: int) -> Row | None:
|
||||
for row in self.prelude:
|
||||
if row.number == number:
|
||||
return row
|
||||
for turn in self.turns:
|
||||
for row in turn.rows:
|
||||
if row.number == number:
|
||||
return row
|
||||
return None
|
||||
|
||||
def turn_of(self, number: int) -> Turn | None:
|
||||
return next((turn for turn in self.turns if any(row.number == number for row in turn.rows)), None)
|
||||
|
||||
|
||||
def build_transcript(messages: Sequence[AnyChatMessage], *, pending_retry_text: str | None = None) -> Transcript:
|
||||
"""Group *messages* into numbered rows and turns.
|
||||
|
||||
A turn starts at a user message and runs until the next one, so tool results,
|
||||
developer notices/checkpoints and synthetic todo nags stay inside the turn
|
||||
they belong to. Rows before the first user message (the injected system
|
||||
prompt, a compact seed summary, a resume notice) form the prelude.
|
||||
|
||||
``pending_retry_text`` marks the last turn as incomplete when it matches that
|
||||
turn's user message (``ChatAgent.pending_retry_text``).
|
||||
"""
|
||||
rows: list[Row] = []
|
||||
number = 0
|
||||
for message in messages:
|
||||
if isinstance(message, SystemChatMessage):
|
||||
# The system prompt is injected locally and never persisted.
|
||||
rows.append(Row(message=message, number=None))
|
||||
continue
|
||||
number += 1
|
||||
rows.append(Row(message=message, number=number))
|
||||
|
||||
prelude: list[Row] = []
|
||||
turns: list[Turn] = []
|
||||
current: list[Row] = []
|
||||
for row in rows:
|
||||
if isinstance(row.message, UserChatMessage):
|
||||
if current:
|
||||
turns.append(Turn(ordinal=len(turns) + 1, rows=tuple(current)))
|
||||
current = [row]
|
||||
elif current:
|
||||
current.append(row)
|
||||
else:
|
||||
prelude.append(row)
|
||||
if current:
|
||||
turns.append(Turn(ordinal=len(turns) + 1, rows=tuple(current)))
|
||||
|
||||
if turns and pending_retry_text is not None:
|
||||
user = turns[-1].user.message
|
||||
if isinstance(user, UserChatMessage) and user.content == pending_retry_text:
|
||||
turns[-1] = replace(turns[-1], incomplete=True)
|
||||
return Transcript(prelude=tuple(prelude), turns=tuple(turns))
|
||||
|
||||
|
||||
def echo_row(row: Row, *, detail: Detail) -> None:
|
||||
"""Print one row: a one-line preview, or the full body for ``full``/``mixed``."""
|
||||
if detail == "preview":
|
||||
click.echo(f"{row.prefix()} {row.preview()}")
|
||||
return
|
||||
if isinstance(row.message, AssistantChatMessage):
|
||||
_echo_assistant(row, reasoning=detail == "full")
|
||||
return
|
||||
click.echo(row.prefix())
|
||||
click.echo(row.message.content or "")
|
||||
click.echo()
|
||||
|
||||
|
||||
def _echo_assistant(row: Row, *, reasoning: bool) -> None:
|
||||
from plyngent.cli.display import markdown_render_available, print_markdown
|
||||
|
||||
message = row.message
|
||||
assert isinstance(message, AssistantChatMessage)
|
||||
click.echo(row.prefix())
|
||||
content = message.content
|
||||
if isinstance(content, str) and content.strip():
|
||||
if markdown_render_available():
|
||||
print_markdown(content, label="")
|
||||
else:
|
||||
click.echo(content)
|
||||
if reasoning:
|
||||
shown = message.reasoning_content
|
||||
if isinstance(shown, str) and shown.strip():
|
||||
click.secho("reasoning:", fg="bright_black")
|
||||
click.echo(shown)
|
||||
names = _tool_call_names(message)
|
||||
if names:
|
||||
click.secho(f" tool_calls=[{', '.join(names)}]", fg="yellow")
|
||||
if not names and not _content(message).strip():
|
||||
click.echo("(empty)")
|
||||
click.echo()
|
||||
|
||||
|
||||
def echo_turn(turn: Turn, *, verbose: bool, detail: Detail) -> None:
|
||||
"""Print one turn: its header, then every row (verbose) or just its ends."""
|
||||
click.secho(turn.label(), fg="yellow" if turn.incomplete else "bright_black")
|
||||
if verbose:
|
||||
rows = turn.rows
|
||||
elif turn.last is turn.user:
|
||||
rows = (turn.user,)
|
||||
else:
|
||||
rows = (turn.user, turn.last)
|
||||
for row in rows:
|
||||
echo_row(row, detail=_row_detail(row, turn=turn, detail=detail))
|
||||
|
||||
|
||||
def _row_detail(row: Row, *, turn: Turn, detail: Detail) -> Detail:
|
||||
"""Effective detail for *row*: mixed prints the turn's answer in full."""
|
||||
if detail != "mixed":
|
||||
return detail
|
||||
return "full" if row is turn.last and isinstance(row.message, AssistantChatMessage) else "preview"
|
||||
|
||||
|
||||
def echo_prelude_rows(rows: Sequence[Row], *, detail: Detail) -> None:
|
||||
"""Print rows that precede the first turn (memory seed, restart notice, …).
|
||||
|
||||
Local-only rows (the injected system prompt) are control-plane noise, so they
|
||||
only appear in a full dump (``-vv``) — numbered head rows always do.
|
||||
"""
|
||||
for row in rows:
|
||||
if row.number is None and detail != "full":
|
||||
continue
|
||||
echo_row(row, detail="preview" if detail == "mixed" else detail)
|
||||
|
||||
|
||||
def _resolve_detail(*, verbose: int, preview: bool) -> Detail:
|
||||
"""Map ``-v`` / ``-vv`` / ``--preview`` onto the detail policy."""
|
||||
if verbose > MAX_VERBOSE:
|
||||
msg = "use at most -vv"
|
||||
raise click.UsageError(msg)
|
||||
if verbose == MAX_VERBOSE and preview:
|
||||
msg = "use only one of -vv / --preview"
|
||||
raise click.UsageError(msg)
|
||||
if verbose == MAX_VERBOSE:
|
||||
return "full"
|
||||
return "preview" if preview else "mixed"
|
||||
|
||||
|
||||
def _echo_message(view: Transcript, head: str, number: int, *, detail: Detail) -> None:
|
||||
"""Print one addressed message in full (``--message``)."""
|
||||
row = view.row(number)
|
||||
if row is None:
|
||||
click.echo(f"error: no message {number} (messages 1-{view.messages})")
|
||||
return
|
||||
body: Detail = "preview" if detail == "preview" else "full"
|
||||
click.echo(f"{head} showing=message {number} mode={body}")
|
||||
echo_row(row, detail=body)
|
||||
owner = view.turn_of(number)
|
||||
if owner is not None:
|
||||
click.secho(f"(turn {owner.ordinal}; /history {owner.ordinal} -v to see it whole)", fg="bright_black")
|
||||
|
||||
|
||||
def _select_turns(
|
||||
view: Transcript,
|
||||
*,
|
||||
target: TurnTarget | None,
|
||||
count: int | None,
|
||||
) -> tuple[tuple[Turn, ...], str] | None:
|
||||
"""Resolve ``/history [N]`` / ``/history last [N]`` into turns to print."""
|
||||
if target is None or target == "last":
|
||||
want = count if count is not None else 1
|
||||
selected = view.turns[-want:]
|
||||
return selected, f"last {len(selected)} turn(s)"
|
||||
turn = view.turn(target)
|
||||
if turn is None:
|
||||
click.echo(f"error: no turn {target} (turns 1-{len(view.turns)})")
|
||||
return None
|
||||
return (turn,), f"turn {target}"
|
||||
|
||||
|
||||
def echo_history(
|
||||
messages: Sequence[AnyChatMessage],
|
||||
*,
|
||||
session_id: int | None,
|
||||
pending_retry_text: str | None = None,
|
||||
target: TurnTarget | None = None,
|
||||
count: int | None = None,
|
||||
message_no: int | None = None,
|
||||
verbose: int = 0,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""Render ``/history``: a turn window, or one addressed message.
|
||||
|
||||
``target`` is a turn number or ``"last"``; ``count`` (only with ``"last"``)
|
||||
is how many turns; ``message_no`` prints a single row instead. Numbering is
|
||||
the display ordinal of this transcript, never a database row number.
|
||||
"""
|
||||
if message_no is not None and (target is not None or count is not None):
|
||||
msg = "use --message N alone"
|
||||
raise click.UsageError(msg)
|
||||
if message_no is not None and verbose:
|
||||
msg = "-v/-vv apply to turns: /history N"
|
||||
raise click.UsageError(msg)
|
||||
if count is not None and target != "last":
|
||||
msg = "a count applies to 'last': /history last [N]"
|
||||
raise click.UsageError(msg)
|
||||
detail = _resolve_detail(verbose=verbose, preview=preview)
|
||||
if not messages:
|
||||
click.echo("(no messages in this session)")
|
||||
return
|
||||
view = build_transcript(messages, pending_retry_text=pending_retry_text)
|
||||
head = f"session={session_id} turns={len(view.turns)} messages={view.messages}"
|
||||
|
||||
if message_no is not None:
|
||||
_echo_message(view, head, message_no, detail=detail)
|
||||
return
|
||||
selection = _select_turns(view, target=target, count=count)
|
||||
if selection is None:
|
||||
return
|
||||
selected, showing = selection
|
||||
click.echo(f"{head} showing={showing} mode={detail}")
|
||||
if verbose >= 1 and view.prelude:
|
||||
echo_prelude_rows(view.prelude, detail=detail)
|
||||
if not selected:
|
||||
click.echo("(no turns yet in this session)")
|
||||
for turn in selected:
|
||||
echo_turn(turn, verbose=verbose >= 1, detail=detail)
|
||||
Reference in New Issue
Block a user