diff --git a/cli.py b/cli.py index 2bc64c9c5b47..23035297f4fa 100644 --- a/cli.py +++ b/cli.py @@ -9947,10 +9947,20 @@ def _on_reasoning(self, reasoning_text: str): def _manual_compress(self, cmd_original: str = ""): """Manually trigger context compression on the current conversation. - Accepts an optional focus topic: ``/compress `` guides the - summariser to preserve information related to *focus* while being - more aggressive about discarding everything else. Inspired by - Claude Code's ``/compact `` feature. + Two modes: + + * ``/compress []`` β€” compress the *whole* history. An + optional focus topic guides the summariser to preserve + information related to *focus* while being more aggressive + about discarding everything else. Inspired by Claude Code's + ``/compact `` feature. + * ``/compress here [N]`` β€” boundary-aware compression. Summarize + everything *except* the most recent ``N`` exchanges (default + 2), which are preserved verbatim. Inspired by Claude Code's + Rewind "Summarize up to here" action (v2.1.139, May 2026, + https://code.claude.com/docs/en/whats-new/2026-w20). Lets the + user pick the compression boundary instead of leaving it to + the automatic token-budget heuristic. """ if not self.conversation_history or len(self.conversation_history) < 4: print("(._.) Not enough conversation to compress (need at least 4 messages).") @@ -9964,12 +9974,21 @@ def _manual_compress(self, cmd_original: str = ""): print("(._.) Compression is disabled in config.") return - # Extract optional focus topic from the command (e.g. "/compress database schema") - focus_topic = "" + from hermes_cli.partial_compress import ( + parse_partial_compress_args, + rejoin_compressed_head_and_tail, + split_history_for_partial_compress, + ) + + # Args after the command word (e.g. "/compress here 3" -> "here 3"). + raw_args = "" if cmd_original: - parts = cmd_original.strip().split(None, 1) - if len(parts) > 1: - focus_topic = parts[1].strip() + _parts = cmd_original.strip().split(None, 1) + if len(_parts) > 1: + raw_args = _parts[1].strip() + + partial, keep_last, focus_topic = parse_partial_compress_args(raw_args) + focus_topic = focus_topic or "" original_count = len(self.conversation_history) with self._busy_command("Compressing context..."): @@ -9977,6 +9996,22 @@ def _manual_compress(self, cmd_original: str = ""): from agent.model_metadata import estimate_request_tokens_rough from agent.manual_compression_feedback import summarize_manual_compression original_history = list(self.conversation_history) + + # Boundary-aware split: only the head is summarized; the + # most recent `keep_last` exchanges ride along verbatim. + tail: list = [] + head = original_history + if partial: + head, tail = split_history_for_partial_compress( + original_history, keep_last + ) + if not tail: + # Split degenerated (everything would be kept, or + # no head left to compress). Fall back to full + # compression so the user still gets an action. + partial = False + head = original_history + # Include system prompt + tool schemas in the estimate β€” # a transcript-only number understates real request pressure # and can even appear to grow after compression because a @@ -9988,7 +10023,11 @@ def _manual_compress(self, cmd_original: str = ""): system_prompt=_sys_prompt, tools=_tools, ) - if focus_topic: + if partial: + print(f"πŸ—œοΈ Summarizing up to here: compressing {len(head)} of " + f"{original_count} messages (~{approx_tokens:,} tokens), " + f"keeping last {keep_last} exchange(s) verbatim...") + elif focus_topic: print(f"πŸ—œοΈ Compressing {original_count} messages (~{approx_tokens:,} tokens), " f"focus: \"{focus_topic}\"...") else: @@ -10001,12 +10040,21 @@ def _manual_compress(self, cmd_original: str = ""): # which already contain the agent identity β€” resulting in the # identity block appearing twice (issue #15281). compressed, _ = self.agent._compress_context( - original_history, + head, None, approx_tokens=approx_tokens, focus_topic=focus_topic or None, force=True, ) + # Re-append the verbatim tail after the compressed head. + # The split guarantees `tail` begins on a user turn, so the + # compressed-head -> tail boundary is normally valid + # (the head's compressed output ends on assistant/tool). + # rejoin_compressed_head_and_tail() additionally guards the + # seam against any illegal user->user / assistant->assistant + # adjacency, defending provider role-alternation rules. + if partial and tail: + compressed = rejoin_compressed_head_and_tail(compressed, tail) self.conversation_history = compressed # _compress_context ends the old session and creates a new child # session on the agent (run_agent.py::_compress_context). Sync the diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 026d8151cebe..7b4d00e818fc 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2804,21 +2804,8 @@ def get_label(slug): return slug try: - # Build provider buttons β€” 2 per row - buttons: list = [] - for p in providers: - count = p.get("total_models", len(p.get("models", []))) - label = f"{p['name']} ({count})" - if p.get("is_current"): - label = f"βœ“ {label}" - # Compact callback data: mp: (max 64 bytes) - buttons.append( - InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}") - ) - - rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] - rows.append([InlineKeyboardButton("βœ— Cancel", callback_data="mx")]) - keyboard = InlineKeyboardMarkup(rows) + # Build provider buttons β€” folds provider groups (display only). + keyboard = self._build_provider_keyboard(providers) provider_label = get_label(current_provider) text = self.format_message( @@ -2865,6 +2852,56 @@ def get_label(slug): _MODEL_PAGE_SIZE = 8 + def _build_provider_keyboard(self, providers: list): + """Build the top-level provider keyboard, folding provider groups. + + Provider families (Kimi/Moonshot, MiniMax, xAI Grok, ...) collapse to + a single ``mpg:`` button; tapping it drills into a member + sub-keyboard. Single providers (and groups with only one authenticated + member) render as direct ``mp:`` buttons. Grouping mirrors the + CLI ``hermes model`` picker via the shared ``group_providers`` fold, + so all surfaces stay consistent. + """ + try: + from hermes_cli.models import group_providers + except Exception: + group_providers = None + + by_slug = {p.get("slug"): p for p in providers} + + def _provider_button(p): + count = p.get("total_models", len(p.get("models", []))) + label = f"{p['name']} ({count})" + if p.get("is_current"): + label = f"βœ“ {label}" + return InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}") + + buttons: list = [] + if group_providers is not None: + for row in group_providers([p.get("slug") for p in providers]): + if row["kind"] == "group": + members = [by_slug[m] for m in row["members"] if m in by_slug] + count = sum( + m.get("total_models", len(m.get("models", []))) for m in members + ) + label = f"{row['label']} β–Έ ({count})" + if any(m.get("is_current") for m in members): + label = f"βœ“ {label}" + buttons.append( + InlineKeyboardButton(label, callback_data=f"mpg:{row['group_id']}") + ) + else: + p = by_slug.get(row["slug"]) + if p is not None: + buttons.append(_provider_button(p)) + else: + for p in providers: + buttons.append(_provider_button(p)) + + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + rows.append([InlineKeyboardButton("βœ— Cancel", callback_data="mx")]) + return InlineKeyboardMarkup(rows) + def _build_model_keyboard(self, models: list, page: int) -> tuple: """Build paginated model buttons. Returns (keyboard, page_info_text).""" page_size = self._MODEL_PAGE_SIZE @@ -3043,10 +3080,23 @@ def get_label(slug): # Clean up state self._model_picker_state.pop(chat_id, None) - elif data == "mb": - # --- Back to provider list --- + elif data.startswith("mpg:"): + # --- Provider group selected: show member providers --- + group_id = data[4:] + try: + from hermes_cli.models import PROVIDER_GROUPS + _label, member_slugs = PROVIDER_GROUPS.get(group_id, ("", [])) + except Exception: + _label, member_slugs = "", [] + + by_slug = {p["slug"]: p for p in state["providers"]} + members = [by_slug[m] for m in member_slugs if m in by_slug] + if not members: + await query.answer(text="Group not found.") + return + buttons = [] - for p in state["providers"]: + for p in members: count = p.get("total_models", len(p.get("models", []))) label = f"{p['name']} ({count})" if p.get("is_current"): @@ -3054,11 +3104,30 @@ def get_label(slug): buttons.append( InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}") ) - rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] - rows.append([InlineKeyboardButton("βœ— Cancel", callback_data="mx")]) + rows.append([ + InlineKeyboardButton("β—€ Back", callback_data="mb"), + InlineKeyboardButton("βœ— Cancel", callback_data="mx"), + ]) keyboard = InlineKeyboardMarkup(rows) + await query.edit_message_text( + text=self.format_message( + ( + f"βš™ *Model Configuration*\n\n" + f"Provider family: *{_label or group_id}*\n\n" + f"Select a provider:" + ) + ), + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=keyboard, + ) + await query.answer() + + elif data == "mb": + # --- Back to provider list (folds groups) --- + keyboard = self._build_provider_keyboard(state["providers"]) + try: provider_label = get_label(state["current_provider"]) except Exception: @@ -3107,7 +3176,7 @@ async def _handle_callback_query( query_user_name = getattr(query.from_user, "first_name", None) # --- Model picker callbacks --- - if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")): + if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")): chat_id = str(query.message.chat_id) if query.message else None if chat_id: await self._handle_model_picker_callback(query, data, chat_id) diff --git a/gateway/run.py b/gateway/run.py index bb618e18527e..5cdc5894cf4d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -12449,6 +12449,12 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: Accepts an optional focus topic: ``/compress `` guides the summariser to preserve information related to *focus* while being more aggressive about discarding everything else. + + Also accepts the boundary-aware form ``/compress here [N]``: + summarize everything except the most recent ``N`` exchanges + (default 2), kept verbatim. Inspired by Claude Code's Rewind + "Summarize up to here" action (v2.1.139, May 2026, + https://code.claude.com/docs/en/whats-new/2026-w20). """ source = event.source session_entry = self.session_store.get_or_create_session(source) @@ -12457,8 +12463,15 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: if not history or len(history) < 4: return t("gateway.compress.not_enough") - # Extract optional focus topic from command args - focus_topic = (event.get_command_args() or "").strip() or None + # Parse args: either a focus topic (full compress) or the + # boundary-aware "here [N]" form (partial compress). + from hermes_cli.partial_compress import ( + parse_partial_compress_args, + rejoin_compressed_head_and_tail, + split_history_for_partial_compress, + ) + _raw_args = (event.get_command_args() or "").strip() + partial, keep_last, focus_topic = parse_partial_compress_args(_raw_args) try: from run_agent import AIAgent @@ -12479,6 +12492,19 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: if m.get("role") in {"user", "assistant"} and m.get("content") ] + # Boundary-aware split: only the head is summarized; the most + # recent `keep_last` exchanges are preserved verbatim. The + # split snaps the tail to a user-turn start so the rejoined + # transcript keeps role alternation valid. + tail: list = [] + head = msgs + if partial: + head, tail = split_history_for_partial_compress(msgs, keep_last) + if not tail: + # Degenerate split β€” fall back to full compression. + partial = False + head = msgs + tmp_agent = AIAgent( **runtime_kwargs, model=model, @@ -12502,15 +12528,20 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: ) compressor = tmp_agent.context_compressor - if not compressor.has_content_to_compress(msgs): + if not compressor.has_content_to_compress(head): return t("gateway.compress.nothing_to_do") loop = asyncio.get_running_loop() compressed, _ = await loop.run_in_executor( None, - lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True) + lambda: tmp_agent._compress_context(head, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True) ) + # Re-append the verbatim tail after the compressed head, + # guarding the seam against illegal role adjacency. + if partial and tail: + compressed = rejoin_compressed_head_and_tail(compressed, tail) + # _compress_context already calls end_session() on the old session # (preserving its full transcript in SQLite) and creates a new # session_id for the continuation. Write the compressed messages diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index dc81ff7e8929..a2db37be20c4 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -85,8 +85,8 @@ class CommandDef: args_hint="", cli_only=True), CommandDef("branch", "Branch the current session (explore a different path)", "Session", aliases=("fork",), args_hint="[name]"), - CommandDef("compress", "Manually compress conversation context", "Session", - args_hint="[focus topic]"), + CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns)", "Session", + args_hint="[here [N] | focus topic]"), CommandDef("rollback", "List or restore filesystem checkpoints", "Session", args_hint="[number]"), CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 76bd12a53e18..f12087e2fa2c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2354,7 +2354,12 @@ def _active_custom_key_from_base_url() -> str: if active == "openrouter" and get_env_value("OPENAI_BASE_URL"): active = "custom" - from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS + from hermes_cli.models import ( + CANONICAL_PROVIDERS, + _PROVIDER_LABELS, + group_providers, + provider_group_for_slug, + ) provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list if active and active in _custom_provider_map: @@ -2367,8 +2372,43 @@ def _active_custom_key_from_base_url() -> str: print(f" Active provider: {active_label}") print() - # Step 1: Provider selection β€” flat list from CANONICAL_PROVIDERS - all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS] + # Step 1: Provider selection. + # + # Canonical providers are folded into top-level groups (display only β€” see + # PROVIDER_GROUPS in hermes_cli/models.py). A multi-member group shows one + # row ("Kimi / Moonshot β–Έ"); picking it opens a member sub-picker that + # resolves back to a concrete slug, so the dispatch chain below is + # unchanged. Custom providers and the trailing actions stay flat. + canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS} + grouped_rows = group_providers([p.slug for p in CANONICAL_PROVIDERS]) + + # The group/slug that should be pre-selected: the active provider's group + # if it's grouped, otherwise the active slug itself. + active_group = provider_group_for_slug(active) if active else "" + + # ordered entries: (key, label, members) + # members == [] β†’ leaf row, key is a provider slug / action + # members != [] β†’ group row, key is "group:" + ordered: list[tuple[str, str, list[str]]] = [] + default_idx = 0 + for row in grouped_rows: + if row["kind"] == "group": + gid = row["group_id"] + label = f"{row['label']} β–Έ" + key = f"group:{gid}" + is_active = bool(active_group) and gid == active_group + members = row["members"] + else: + slug = row["slug"] + label = canonical_descs.get(slug, provider_labels.get(slug, slug)) + key = slug + is_active = bool(active) and slug == active + members = [] + if is_active: + ordered.append((key, f"{label} ← currently active", members)) + default_idx = len(ordered) - 1 + else: + ordered.append((key, label, members)) for key, provider_info in _custom_provider_map.items(): name = provider_info["name"] @@ -2376,36 +2416,49 @@ def _active_custom_key_from_base_url() -> str: short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/") saved_model = provider_info.get("model", "") model_hint = f" β€” {saved_model}" if saved_model else "" - all_providers.append((key, f"{name} ({short_url}){model_hint}")) - - # Build the menu - ordered = [] - default_idx = 0 - for key, label in all_providers: + label = f"{name} ({short_url}){model_hint}" if active and key == active: - ordered.append((key, f"{label} ← currently active")) + ordered.append((key, f"{label} ← currently active", [])) default_idx = len(ordered) - 1 else: - ordered.append((key, label)) + ordered.append((key, label, [])) - ordered.append(("custom", "Custom endpoint (enter URL manually)")) + ordered.append(("custom", "Custom endpoint (enter URL manually)", [])) _has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool( config.get("custom_providers") ) if _has_saved_custom_list: - ordered.append(("remove-custom", "Remove a saved custom provider")) - ordered.append(("aux-config", "Configure auxiliary models...")) - ordered.append(("cancel", "Leave unchanged")) + ordered.append(("remove-custom", "Remove a saved custom provider", [])) + ordered.append(("aux-config", "Configure auxiliary models...", [])) + ordered.append(("cancel", "Leave unchanged", [])) provider_idx = _prompt_provider_choice( - [label for _, label in ordered], + [label for _, label, _ in ordered], default=default_idx, ) if provider_idx is None or ordered[provider_idx][0] == "cancel": print("No change.") return - selected_provider = ordered[provider_idx][0] + selected_key = ordered[provider_idx][0] + selected_members = ordered[provider_idx][2] + + # Group row β†’ drill into a member sub-picker. Default to the active member + # if the active provider lives in this group. + if selected_members: + member_default = 0 + if active in selected_members: + member_default = selected_members.index(active) + member_labels = [ + canonical_descs.get(m, provider_labels.get(m, m)) for m in selected_members + ] + member_idx = _prompt_provider_choice(member_labels, default=member_default) + if member_idx is None: + print("No change.") + return + selected_provider = selected_members[member_idx] + else: + selected_provider = selected_key if selected_provider == "aux-config": _aux_config_menu() diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 42eadfd76290..fba6ec94cfdd 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -936,6 +936,105 @@ class ProviderEntry(NamedTuple): _PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider +# --------------------------------------------------------------------------- +# Provider groups β€” DISPLAY ONLY +# +# Some vendors expose several Hermes provider slugs (one per endpoint / +# auth method: global API, China API, OAuth coding plan, ...). Listing every +# slug as a top-level row in the interactive `hermes model` / setup wizard / +# Telegram `/model` pickers makes that list long and noisy. +# +# These groups fold related slugs under one top-level row in INTERACTIVE +# PICKERS only. They do NOT change ``CANONICAL_PROVIDERS``, slug identity, +# the ``--provider`` flag, ``/model ``, or any typed path β€” +# every member slug remains individually addressable. Grouping is a pure +# display affordance; ``group_providers()`` is the single fold used by all +# three picker surfaces so they stay consistent. +# +# group_id -> (display_label, [member_slug, ...]) +# +# Member order is the order shown inside the group submenu. +# --------------------------------------------------------------------------- +PROVIDER_GROUPS: dict[str, tuple[str, list[str]]] = { + "kimi": ("Kimi / Moonshot", ["kimi-coding", "kimi-coding-cn"]), + "minimax": ("MiniMax", ["minimax", "minimax-oauth", "minimax-cn"]), + "xai": ("xAI Grok", ["xai", "xai-oauth"]), + "google": ("Google Gemini", ["gemini", "google-gemini-cli"]), + "openai": ("OpenAI", ["openai-codex", "openai-api"]), + "opencode": ("OpenCode", ["opencode-zen", "opencode-go"]), + "copilot": ("GitHub Copilot", ["copilot", "copilot-acp"]), +} + +# Reverse index: member slug -> group_id. Built once at import. +_SLUG_TO_GROUP: dict[str, str] = { + slug: gid for gid, (_label, members) in PROVIDER_GROUPS.items() for slug in members +} + + +def provider_group_for_slug(slug: str) -> str: + """Return the group_id a provider slug belongs to, or "" if ungrouped.""" + return _SLUG_TO_GROUP.get(str(slug or "").strip().lower(), "") + + +def group_providers(slugs): + """Fold a flat ordered slug iterable into picker rows by provider group. + + DISPLAY ONLY. Used by every interactive picker (``hermes model``, the + setup wizard, the Telegram ``/model`` keyboard) so grouping is identical + across surfaces. + + Each returned row is a dict:: + + {"kind": "single", "slug": } # ungrouped, or + # 1-member group + {"kind": "group", "group_id": , "label":