diff --git a/cli.py b/cli.py index 0683838d8..c3f438690 100644 --- a/cli.py +++ b/cli.py @@ -9314,9 +9314,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): return from hermes_cli.partial_compress import ( + extract_compress_flags, parse_partial_compress_args, rejoin_compressed_head_and_tail, split_history_for_partial_compress, + summarize_compress_preview, ) # Args after the command word (e.g. "/compress here 3" -> "here 3"). @@ -9326,9 +9328,42 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): if len(_parts) > 1: raw_args = _parts[1].strip() + # Strip --preview/--dry-run/--aggressive before positional parsing + # so the flags coexist with 'here [N]' / focus-topic forms. + raw_args, preview, aggressive = extract_compress_flags(raw_args) partial, keep_last, focus_topic = parse_partial_compress_args(raw_args) focus_topic = focus_topic or "" + if aggressive: + # LLM-free hard truncation is not supported: it would need its + # own transcript-persistence path outside the guarded + # _compress_context rotation machinery. Surface that instead of + # silently mis-parsing the flag as a focus topic. + print("(._.) --aggressive is not supported; use '/compress here [N]' " + "to keep only recent exchanges, or /undo to drop turns.") + if not preview: + return + + if preview: + from agent.model_metadata import estimate_request_tokens_rough + _sys_prompt = getattr(self.agent, "_cached_system_prompt", "") or "" + _tools = getattr(self.agent, "tools", None) or None + approx_tokens = estimate_request_tokens_rough( + self.conversation_history, + system_prompt=_sys_prompt, + tools=_tools, + ) + report = summarize_compress_preview( + self.conversation_history, + partial, + keep_last, + focus_topic or None, + approx_tokens, + ) + for line in report["lines"]: + print(f"🗜️ {line}") + return + original_count = len(self.conversation_history) with self._busy_command("Compressing context..."): try: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 199e79294..341efb4b0 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3044,13 +3044,44 @@ class GatewaySlashCommandsMixin: # Parse args: either a focus topic (full compress) or the # boundary-aware "here [N]" form (partial compress). from hermes_cli.partial_compress import ( + extract_compress_flags, parse_partial_compress_args, rejoin_compressed_head_and_tail, split_history_for_partial_compress, + summarize_compress_preview, ) _raw_args = (event.get_command_args() or "").strip() + # Strip --preview/--dry-run/--aggressive before positional parsing + # so the flags coexist with 'here [N]' / focus-topic forms. + _raw_args, _preview, _aggressive = extract_compress_flags(_raw_args) partial, keep_last, focus_topic = parse_partial_compress_args(_raw_args) + _agg_note = "" + if _aggressive: + # LLM-free hard truncation is not supported on this surface — + # it would need its own transcript-persistence branch outside + # the guarded _compress_context rotation machinery (#44794). + _agg_note = t("gateway.compress.aggressive_unsupported") + if not _preview: + return _agg_note + + if _preview: + # Report what WOULD be compressed — no agent, no writes. + from agent.model_metadata import estimate_request_tokens_rough + _pv_msgs = [ + {"role": m.get("role"), "content": m.get("content")} + for m in history + if m.get("role") in {"user", "assistant"} and m.get("content") + ] + approx_tokens = estimate_request_tokens_rough(_pv_msgs) + report = summarize_compress_preview( + _pv_msgs, partial, keep_last, focus_topic, approx_tokens + ) + lines = [f"🗜️ {line}" for line in report["lines"]] + if _aggressive: + lines.append(_agg_note) + return "\n".join(lines) + try: from run_agent import AIAgent from agent.manual_compression_feedback import summarize_manual_compression diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index fc8c0a1da..7a8775d26 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -88,8 +88,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="", cli_only=True), CommandDef("branch", "Branch the current session (explore a different path)", "Session", aliases=("fork",), args_hint="[name]"), - CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns)", "Session", - args_hint="[here [N] | focus topic]"), + CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns; --preview shows what would happen)", "Session", + aliases=("compact",), args_hint="[here [N] | focus topic | --preview|--dry-run]"), 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/partial_compress.py b/hermes_cli/partial_compress.py index dc1115d9f..129a9fc6b 100644 --- a/hermes_cli/partial_compress.py +++ b/hermes_cli/partial_compress.py @@ -108,6 +108,95 @@ def parse_partial_compress_args( return False, DEFAULT_KEEP_LAST, text or None +def extract_compress_flags(raw_args: str) -> Tuple[str, bool, bool]: + """Strip ``--preview``/``--dry-run``/``--aggressive`` flags from the + argument string after ``/compress`` (or its ``/compact`` alias). + + Flags may appear anywhere and coexist with the positional forms + (``here [N]``, ``--keep N``, or a focus topic); the returned + remainder is what :func:`parse_partial_compress_args` should see. + + Returns ``(remaining_args, preview, aggressive_requested)``: + + * ``preview`` — True when ``--preview`` or ``--dry-run`` was given. + The caller must report what WOULD be compressed (message counts, + token estimate, boundary) and make **no changes**. + * ``aggressive_requested`` — True when ``--aggressive`` was given. + The current surfaces do not implement an LLM-free hard-truncate + path (it would need its own transcript-persistence branch outside + the guarded ``_compress_context`` rotation machinery), so callers + surface a "not supported" note instead of silently treating the + flag as a focus topic. + """ + preview = False + aggressive = False + kept: List[str] = [] + for tok in (raw_args or "").split(): + low = tok.lower() + if low in ("--preview", "--dry-run", "--dryrun"): + preview = True + elif low == "--aggressive": + aggressive = True + else: + kept.append(tok) + return " ".join(kept), preview, aggressive + + +def summarize_compress_preview( + history: List[Dict[str, Any]], + partial: bool, + keep_last: int, + focus_topic: Optional[str], + approx_tokens: int, +) -> Dict[str, Any]: + """Build the ``/compress --preview`` report — pure, no side effects. + + Shared by the CLI (``cli.py::_manual_compress``) and the gateway + (``gateway/slash_commands.py::_handle_compress_command``) so both + surfaces report the same numbers the real run would use. + + Returns a dict with ``head_count``/``tail_count``/``lines`` where + ``lines`` is a ready-to-print list of report strings. + """ + total = len(history) + head = list(history) + tail: List[Dict[str, Any]] = [] + effective_partial = partial + if partial: + head, tail = split_history_for_partial_compress(history, keep_last) + if not tail: + # Same degenerate-split fallback the real run applies. + effective_partial = False + head, tail = list(history), [] + + lines = [ + "Preview — no changes made.", + f"Would compress {len(head)} of {total} message(s) " + f"(~{approx_tokens:,} tokens currently in context).", + ] + if effective_partial: + lines.append( + f"Boundary: keeping the last {keep_last} exchange(s) " + f"({len(tail)} message(s)) verbatim." + ) + elif partial: + lines.append( + "Boundary: 'here' split would keep everything — " + "falling back to full compression." + ) + if focus_topic: + lines.append(f'Focus topic: "{focus_topic}"') + lines.append("Run the command again without --preview to apply.") + + return { + "head_count": len(head), + "tail_count": len(tail), + "total": total, + "partial": effective_partial, + "lines": lines, + } + + def _coerce_keep(value: str) -> int: """Parse a keep-count token, clamping to [1, MAX_KEEP_LAST].""" try: diff --git a/locales/af.yaml b/locales/af.yaml index a29f97dbc..18dd53058 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "Nie genoeg gesprek om saam te pers nie (ten minste 4 boodskappe nodig)." no_provider: "Geen verskaffer opgestel nie -- kan nie saampers nie." nothing_to_do: "Niks om saam te pers nie (die transkripsie is steeds heeltemal beskermde konteks)." + aggressive_unsupported: "--aggressive word nie ondersteun nie; gebruik '/compress here [N]' om net onlangse uitruilings te behou, of /undo om beurte te verwyder." focus_line: "Fokus: \"{topic}\"" summary_failed: "⚠️ Opsomming kon nie gegenereer word nie ({error}). {count} historiese boodskap(pe) is verwyder en met 'n plekhouer vervang; vroeëre konteks kan nie meer herstel word nie. Oorweeg om jou auxiliary.compression-modelopstelling na te gaan." aborted: "⚠️ Kompressie gestaak ({error}). Geen boodskappe is laat val nie — die gesprek is onveranderd. Voer /compress uit om weer te probeer, /reset vir 'n skoon sessie, of kyk na jou auxiliary.compression-modelkonfigurasie." diff --git a/locales/de.yaml b/locales/de.yaml index db6174f42..fed360785 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "Nicht genug Konversation zum Komprimieren (mindestens 4 Nachrichten erforderlich)." no_provider: "Kein Anbieter konfiguriert — Komprimierung nicht möglich." nothing_to_do: "Noch nichts zu komprimieren (das Transkript ist weiterhin vollständig geschützter Kontext)." + aggressive_unsupported: "--aggressive wird nicht unterstützt; verwende '/compress here [N]', um nur die letzten Austausche zu behalten, oder /undo, um Beiträge zu entfernen." focus_line: "Fokus: \"{topic}\"" summary_failed: "⚠️ Zusammenfassungsgenerierung fehlgeschlagen ({error}). {count} historische Nachricht(en) wurden entfernt und durch einen Platzhalter ersetzt; früherer Kontext ist nicht mehr wiederherstellbar. Überprüfen Sie die Konfiguration des auxiliary.compression-Modells." aborted: "⚠️ Komprimierung abgebrochen ({error}). Keine Nachrichten wurden entfernt — die Konversation ist unverändert. Führe /compress aus, um es erneut zu versuchen, /reset für eine neue Sitzung, oder prüfe deine auxiliary.compression-Modellkonfiguration." diff --git a/locales/en.yaml b/locales/en.yaml index 9a9bf2067..da2610be9 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -103,6 +103,7 @@ gateway: not_enough: "Not enough conversation to compress (need at least 4 messages)." no_provider: "No provider configured -- cannot compress." nothing_to_do: "Nothing to compress yet (the transcript is still all protected context)." + aggressive_unsupported: "--aggressive is not supported; use '/compress here [N]' to keep only recent exchanges, or /undo to drop turns." focus_line: "Focus: \"{topic}\"" summary_failed: "⚠️ Summary generation failed ({error}). {count} historical message(s) were removed and replaced with a placeholder; earlier context is no longer recoverable. Consider checking your auxiliary.compression model configuration." aborted: "⚠️ Compression aborted ({error}). No messages were dropped — conversation is unchanged. Run /compress to retry, /reset for a clean session, or check your auxiliary.compression model configuration." diff --git a/locales/es.yaml b/locales/es.yaml index e23e53f56..518a4b793 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "No hay suficiente conversación para comprimir (se necesitan al menos 4 mensajes)." no_provider: "No hay proveedor configurado — no se puede comprimir." nothing_to_do: "Aún no hay nada que comprimir (la transcripción sigue siendo todo contexto protegido)." + aggressive_unsupported: "--aggressive no es compatible; usa '/compress here [N]' para conservar solo los intercambios recientes, o /undo para eliminar turnos." focus_line: "Enfoque: \"{topic}\"" summary_failed: "⚠️ Falló la generación del resumen ({error}). Se eliminaron {count} mensaje(s) históricos y se reemplazaron por un marcador; el contexto anterior ya no se puede recuperar. Considera revisar la configuración del modelo auxiliary.compression." aborted: "⚠️ Compresión abortada ({error}). No se eliminó ningún mensaje — la conversación está intacta. Ejecuta /compress para reintentar, /reset para una sesión limpia, o revisa la configuración de tu modelo auxiliary.compression." diff --git a/locales/fr.yaml b/locales/fr.yaml index 7fb48aaa6..f6730ccdb 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "Conversation insuffisante pour la compression (au moins 4 messages nécessaires)." no_provider: "Aucun fournisseur configuré — compression impossible." nothing_to_do: "Rien à compresser pour l'instant (la transcription est encore entièrement du contexte protégé)." + aggressive_unsupported: "--aggressive n'est pas pris en charge ; utilisez '/compress here [N]' pour ne conserver que les échanges récents, ou /undo pour supprimer des tours." focus_line: "Focus : \"{topic}\"" summary_failed: "⚠️ Échec de la génération du résumé ({error}). {count} message(s) historique(s) ont été supprimés et remplacés par un espace réservé ; le contexte antérieur n'est plus récupérable. Vérifiez la configuration du modèle auxiliary.compression." aborted: "⚠️ Compression interrompue ({error}). Aucun message n'a été supprimé — la conversation est inchangée. Lancez /compress pour réessayer, /reset pour une nouvelle session, ou vérifiez la configuration de votre modèle auxiliary.compression." diff --git a/locales/ga.yaml b/locales/ga.yaml index 5ca740fca..26ede56fd 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -92,6 +92,7 @@ gateway: not_enough: "Níl go leor comhrá le dlúthú (teastaíonn 4 theachtaireacht ar a laghad)." no_provider: "Níl aon soláthraí cumraithe — ní féidir dlúthú." nothing_to_do: "Níl aon rud le dlúthú fós (tá an traschríbhinn fós uile mar chomhthéacs cosanta)." + aggressive_unsupported: "Ní thacaítear le --aggressive; úsáid '/compress here [N]' chun na malartuithe is déanaí amháin a choinneáil, nó /undo chun sealanna a scriosadh." focus_line: "Fócas: \"{topic}\"" summary_failed: "⚠️ Theip ar ghiniúint achoimre ({error}). Baineadh {count} teachtaireacht stairiúil agus cuireadh ionadaí ina n-áit; níl an comhthéacs roimhe seo in-aisghabhála a thuilleadh. Smaoinigh ar an gcumraíocht auxiliary.compression a sheiceáil." aborted: "⚠️ Cuireadh deireadh leis an dlúthú ({error}). Níor baineadh aon teachtaireacht — tá an comhrá gan athrú. Rith /compress chun é a thriail arís, /reset le haghaidh seisiún glan, nó seiceáil do chumraíocht samhla auxiliary.compression." diff --git a/locales/hu.yaml b/locales/hu.yaml index 02a877724..b98272b9f 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "Nincs elég beszélgetés a tömörítéshez (legalább 4 üzenet kell)." no_provider: "Nincs konfigurált szolgáltató — nem lehet tömöríteni." nothing_to_do: "Még nincs mit tömöríteni (a teljes átirat még védett kontextus)." + aggressive_unsupported: "A --aggressive nem támogatott; használd a '/compress here [N]' parancsot, hogy csak a legutóbbi váltásokat tartsd meg, vagy a /undo parancsot körök törléséhez." focus_line: "Fókusz: \"{topic}\"" summary_failed: "⚠️ Az összefoglaló generálása sikertelen ({error}). {count} korábbi üzenet eltávolítva és helykitöltővel helyettesítve; a korábbi kontextus már nem helyreállítható. Érdemes ellenőrizni az auxiliary.compression modell konfigurációját." aborted: "⚠️ Tömörítés megszakítva ({error}). Egyetlen üzenet sem lett eldobva — a beszélgetés változatlan. Futtass /compress parancsot az újrapróbálkozáshoz, /reset egy új munkamenethez, vagy ellenőrizd az auxiliary.compression modell konfigurációt." diff --git a/locales/it.yaml b/locales/it.yaml index d3bff53b5..5f0711e84 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "Conversazione insufficiente da comprimere (servono almeno 4 messaggi)." no_provider: "Nessun provider configurato — impossibile comprimere." nothing_to_do: "Niente da comprimere per ora (la trascrizione è ancora tutta contesto protetto)." + aggressive_unsupported: "--aggressive non è supportato; usa '/compress here [N]' per conservare solo gli scambi recenti, oppure /undo per rimuovere i turni." focus_line: "Focus: \"{topic}\"" summary_failed: "⚠️ Generazione del riepilogo non riuscita ({error}). {count} messaggio/i storico/i sono stati rimossi e sostituiti con un segnaposto; il contesto precedente non è più recuperabile. Considera di controllare la configurazione del modello auxiliary.compression." aborted: "⚠️ Compressione interrotta ({error}). Nessun messaggio è stato eliminato — la conversazione è invariata. Esegui /compress per riprovare, /reset per una nuova sessione, o controlla la configurazione del modello auxiliary.compression." diff --git a/locales/ja.yaml b/locales/ja.yaml index 7bce7e41e..02d4df8ef 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "圧縮するための会話が不十分です (少なくとも 4 件のメッセージが必要)。" no_provider: "プロバイダーが構成されていません — 圧縮できません。" nothing_to_do: "まだ圧縮するものがありません (トランスクリプトはすべて保護されたコンテキストのままです)。" + aggressive_unsupported: "--aggressive はサポートされていません。'/compress here [N]' で直近のやり取りだけを残すか、/undo でターンを削除してください。" focus_line: "フォーカス: \"{topic}\"" summary_failed: "⚠️ 要約の生成に失敗しました ({error})。{count} 件の履歴メッセージが削除され、プレースホルダーに置き換えられました。以前のコンテキストは復元できません。auxiliary.compression モデルの設定を確認してください。" aborted: "⚠️ 圧縮が中止されました ({error})。メッセージは削除されていません — 会話はそのままです。再試行するには /compress、新しいセッションを開始するには /reset を実行するか、auxiliary.compression モデル設定を確認してください。" diff --git a/locales/ko.yaml b/locales/ko.yaml index 9fcce0d9a..5404ec36c 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "압축할 대화가 충분하지 않습니다 (최소 4개의 메시지가 필요합니다)." no_provider: "구성된 제공자가 없습니다 -- 압축할 수 없습니다." nothing_to_do: "아직 압축할 내용이 없습니다 (대화 내용이 모두 보호된 컨텍스트입니다)." + aggressive_unsupported: "--aggressive는 지원되지 않습니다. '/compress here [N]'으로 최근 대화만 유지하거나 /undo로 턴을 삭제하세요." focus_line: "초점: \"{topic}\"" summary_failed: "⚠️ 요약 생성에 실패했습니다 ({error}). 과거 메시지 {count}개가 제거되어 자리표시자로 대체되었으며, 이전 컨텍스트는 더 이상 복구할 수 없습니다. auxiliary.compression 모델 설정을 확인해 보세요." aborted: "⚠️ 압축이 중단되었습니다 ({error}). 메시지가 삭제되지 않았으며 대화는 그대로 유지됩니다. 다시 시도하려면 /compress를 실행하거나, 새 세션을 시작하려면 /reset을 사용하거나, auxiliary.compression 모델 설정을 확인하세요." diff --git a/locales/pt.yaml b/locales/pt.yaml index a406e3de7..ee9b95bb9 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "Não há conversa suficiente para comprimir (são necessárias pelo menos 4 mensagens)." no_provider: "Nenhum fornecedor configurado — não é possível comprimir." nothing_to_do: "Ainda não há nada para comprimir (a transcrição continua a ser todo o contexto protegido)." + aggressive_unsupported: "--aggressive não é suportado; usa '/compress here [N]' para manter apenas as trocas recentes, ou /undo para remover turnos." focus_line: "Foco: \"{topic}\"" summary_failed: "⚠️ Falha ao gerar o resumo ({error}). {count} mensagem(ns) histórica(s) foram removidas e substituídas por um marcador; o contexto anterior já não pode ser recuperado. Considera verificar a configuração do modelo auxiliary.compression." aborted: "⚠️ Compressão abortada ({error}). Nenhuma mensagem foi removida — a conversa está inalterada. Executa /compress para tentar de novo, /reset para uma sessão nova, ou verifica a configuração do modelo auxiliary.compression." diff --git a/locales/ru.yaml b/locales/ru.yaml index 995335b28..3628f1e25 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "Недостаточно беседы для сжатия (нужно минимум 4 сообщения)." no_provider: "Провайдер не настроен — сжатие невозможно." nothing_to_do: "Пока нечего сжимать (стенограмма всё ещё полностью является защищённым контекстом)." + aggressive_unsupported: "--aggressive не поддерживается; используйте '/compress here [N]', чтобы сохранить только недавние обмены, или /undo, чтобы удалить ходы." focus_line: "Фокус: \"{topic}\"" summary_failed: "⚠️ Не удалось сгенерировать сводку ({error}). {count} историч. сообщений было удалено и заменено заполнителем; предыдущий контекст больше нельзя восстановить. Проверьте конфигурацию модели auxiliary.compression." aborted: "⚠️ Сжатие прервано ({error}). Сообщения не были удалены — разговор не изменился. Запустите /compress для повторной попытки, /reset для новой сессии или проверьте конфигурацию модели auxiliary.compression." diff --git a/locales/tr.yaml b/locales/tr.yaml index 5e67a951e..32f1d6b72 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "Sıkıştırmak için yeterli konuşma yok (en az 4 mesaj gerekli)." no_provider: "Yapılandırılmış sağlayıcı yok — sıkıştırılamıyor." nothing_to_do: "Henüz sıkıştırılacak bir şey yok (transkript hâlâ tamamen korunan bağlam)." + aggressive_unsupported: "--aggressive desteklenmiyor; yalnızca son alışverişleri tutmak için '/compress here [N]' kullanın veya turları silmek için /undo kullanın." focus_line: "Odak: \"{topic}\"" summary_failed: "⚠️ Özet oluşturma başarısız ({error}). {count} geçmiş mesaj kaldırılıp yer tutucuyla değiştirildi; önceki bağlam artık kurtarılamaz. auxiliary.compression model yapılandırmanızı kontrol edin." aborted: "⚠️ Sıkıştırma iptal edildi ({error}). Hiçbir mesaj silinmedi — konuşma değişmedi. Tekrar denemek için /compress, temiz bir oturum için /reset komutunu çalıştırın veya auxiliary.compression model yapılandırmanızı kontrol edin." diff --git a/locales/uk.yaml b/locales/uk.yaml index 8e9123450..af43c7e8d 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "Недостатньо розмови для стиснення (потрібно щонайменше 4 повідомлення)." no_provider: "Постачальника не налаштовано — неможливо стиснути." nothing_to_do: "Поки що немає що стискати (стенограма все ще є повністю захищеним контекстом)." + aggressive_unsupported: "--aggressive не підтримується; використовуйте '/compress here [N]', щоб зберегти лише останні обміни, або /undo, щоб видалити ходи." focus_line: "Фокус: \"{topic}\"" summary_failed: "⚠️ Не вдалося згенерувати зведення ({error}). {count} історичних повідомлень було видалено та замінено заповнювачем; попередній контекст більше не можна відновити. Перевірте конфігурацію моделі auxiliary.compression." aborted: "⚠️ Стиснення скасовано ({error}). Жодне повідомлення не було видалено — розмова не змінилася. Виконайте /compress, щоб повторити спробу, /reset для нової сесії, або перевірте конфігурацію моделі auxiliary.compression." diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 1190e23d0..3f34fd263 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "對話內容不足,無法壓縮(至少需要 4 則訊息)。" no_provider: "未設定提供方 — 無法壓縮。" nothing_to_do: "目前沒有可壓縮的內容(對話記錄仍全部為受保護的上下文)。" + aggressive_unsupported: "不支援 --aggressive;請使用 '/compress here [N]' 只保留最近的對話,或使用 /undo 刪除回合。" focus_line: "聚焦:\"{topic}\"" summary_failed: "⚠️ 摘要產生失敗({error})。{count} 則歷史訊息已被移除並以佔位符取代;先前的上下文已無法復原。建議檢查 auxiliary.compression 模型設定。" aborted: "⚠️ 壓縮已中止 ({error})。未刪除任何訊息 — 對話保持不變。執行 /compress 重試,執行 /reset 開始新工作階段,或檢查你的 auxiliary.compression 模型設定。" diff --git a/locales/zh.yaml b/locales/zh.yaml index e8444394a..6183612b5 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -88,6 +88,7 @@ gateway: not_enough: "对话内容不足,无法压缩(至少需要 4 条消息)。" no_provider: "未配置提供方 — 无法压缩。" nothing_to_do: "暂无可压缩内容(对话记录仍全部为受保护上下文)。" + aggressive_unsupported: "不支持 --aggressive;请使用 '/compress here [N]' 只保留最近的对话,或使用 /undo 删除回合。" focus_line: "聚焦:\"{topic}\"" summary_failed: "⚠️ 摘要生成失败({error})。{count} 条历史消息已被移除并替换为占位符;之前的上下文已无法恢复。建议检查 auxiliary.compression 模型配置。" aborted: "⚠️ 压缩已中止 ({error})。未删除任何消息 — 对话保持不变。运行 /compress 重试,运行 /reset 开始新会话,或检查你的 auxiliary.compression 模型配置。" diff --git a/scripts/release.py b/scripts/release.py index 0c5a481e9..21af630ce 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "louis@letsfive.io": "Mibayy", # PR #3243 salvage (/compact alias + preview/aggressive flags for /compress) "ai-lab@foxmail.com": "CrazyBoyM", # PR #55828 salvage (image_gen openai-codex: wire image-to-image / reference-image editing via Codex Responses input_image parts; magic-byte + read-guard + 25MB-cap + clamp-to-16 hardening) "r0gersm1th@users.noreply.github.com": "r0gersm1th", # PR #3219 salvage (whatsapp bridge: resolve LID sender IDs to phone numbers in the message payload so phone-based allowlists match; commit authored by collaborator r0gersm1th, PR by @ajmeese7) "louis@letsfive.io": "Mibayy", # PR #3296 salvage (status: provider label honors config.yaml model.base_url, not just OPENAI_BASE_URL env) diff --git a/tests/cli/test_compress_flags.py b/tests/cli/test_compress_flags.py new file mode 100644 index 000000000..be7ddcc0c --- /dev/null +++ b/tests/cli/test_compress_flags.py @@ -0,0 +1,155 @@ +"""Tests for /compress --preview/--dry-run/--aggressive flags and the +/compact alias (PR #3243 salvage). + +Covers the pure helpers in ``hermes_cli.partial_compress`` plus alias +resolution in the command registry. The CLI and gateway surfaces both +route through these helpers, so the flag semantics are pinned here once. +""" + +from hermes_cli.commands import COMMANDS, resolve_command +from hermes_cli.partial_compress import ( + DEFAULT_KEEP_LAST, + extract_compress_flags, + parse_partial_compress_args, + summarize_compress_preview, +) + + +def _history(n_pairs: int) -> list[dict[str, str]]: + h: list[dict[str, str]] = [] + for i in range(n_pairs): + h.append({"role": "user", "content": f"u{i}"}) + h.append({"role": "assistant", "content": f"a{i}"}) + return h + + +# ── /compact alias resolution ───────────────────────────────────────── + + +def test_compact_resolves_to_compress(): + cmd = resolve_command("compact") + assert cmd is not None + assert cmd.name == "compress" + assert "compact" in cmd.aliases + + +def test_compact_alias_with_slash(): + cmd = resolve_command("/compact") + assert cmd is not None and cmd.name == "compress" + + +def test_compact_listed_in_flat_commands(): + assert "/compact" in COMMANDS + assert "alias for /compress" in COMMANDS["/compact"] + + +def test_compress_args_hint_documents_preview(): + cmd = resolve_command("compress") + assert cmd is not None + assert "--preview" in (cmd.args_hint or "") + + +# ── extract_compress_flags ──────────────────────────────────────────── + + +def test_no_flags_passthrough(): + rest, preview, aggressive = extract_compress_flags("here 3") + assert rest == "here 3" + assert preview is False + assert aggressive is False + + +def test_preview_flag_stripped(): + rest, preview, aggressive = extract_compress_flags("--preview") + assert rest == "" + assert preview is True + assert aggressive is False + + +def test_dry_run_is_preview(): + for form in ("--dry-run", "--dryrun", "--DRY-RUN"): + _, preview, _ = extract_compress_flags(form) + assert preview is True, form + + +def test_aggressive_flag_detected(): + rest, preview, aggressive = extract_compress_flags("--aggressive") + assert rest == "" + assert preview is False + assert aggressive is True + + +def test_flags_coexist_with_here_form(): + rest, preview, aggressive = extract_compress_flags("--preview here 4") + assert rest == "here 4" + assert preview is True + partial, keep, focus = parse_partial_compress_args(rest) + assert partial is True and keep == 4 and focus is None + + +def test_flags_coexist_with_focus_topic(): + rest, preview, _ = extract_compress_flags("database schema --dry-run") + assert rest == "database schema" + assert preview is True + partial, _, focus = parse_partial_compress_args(rest) + assert partial is False and focus == "database schema" + + +def test_aggressive_dry_run_combo(): + rest, preview, aggressive = extract_compress_flags("--aggressive --dry-run") + assert rest == "" + assert preview is True and aggressive is True + + +def test_empty_args(): + rest, preview, aggressive = extract_compress_flags("") + assert rest == "" and preview is False and aggressive is False + + +# ── summarize_compress_preview ──────────────────────────────────────── + + +def test_preview_full_compress_counts(): + hist = _history(5) + report = summarize_compress_preview(hist, False, DEFAULT_KEEP_LAST, None, 1234) + assert report["head_count"] == 10 + assert report["tail_count"] == 0 + assert report["total"] == 10 + assert report["partial"] is False + joined = "\n".join(report["lines"]) + assert "no changes made" in joined.lower() + assert "10 of 10" in joined + assert "1,234" in joined + + +def test_preview_partial_boundary_counts(): + hist = _history(5) + report = summarize_compress_preview(hist, True, 2, None, 999) + # Keeping last 2 exchanges = 4 tail messages, 6 head messages. + assert report["head_count"] == 6 + assert report["tail_count"] == 4 + assert report["partial"] is True + joined = "\n".join(report["lines"]) + assert "last 2 exchange" in joined + + +def test_preview_partial_degenerate_falls_back_to_full(): + hist = _history(2) # keep_last=5 would swallow everything + report = summarize_compress_preview(hist, True, 5, None, 100) + assert report["partial"] is False + assert report["head_count"] == 4 + joined = "\n".join(report["lines"]) + assert "falling back to full compression" in joined + + +def test_preview_includes_focus_topic(): + hist = _history(4) + report = summarize_compress_preview(hist, False, DEFAULT_KEEP_LAST, "db schema", 50) + assert 'Focus topic: "db schema"' in "\n".join(report["lines"]) + + +def test_preview_is_side_effect_free(): + hist = _history(4) + before = [dict(m) for m in hist] + summarize_compress_preview(hist, True, 1, None, 10) + assert hist == before diff --git a/tests/gateway/test_compress_preview.py b/tests/gateway/test_compress_preview.py new file mode 100644 index 000000000..62eeda06d --- /dev/null +++ b/tests/gateway/test_compress_preview.py @@ -0,0 +1,120 @@ +"""Tests for gateway /compress --preview/--dry-run/--aggressive flags +(PR #3243 salvage). + +The preview path must return a report WITHOUT building an agent or +touching the transcript; --aggressive must return an explanatory +message rather than being mis-parsed as a focus topic. +""" + +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source() -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + user_id="u1", + chat_id="c1", + user_name="tester", + chat_type="dm", + ) + + +def _make_event(text: str) -> MessageEvent: + return MessageEvent(text=text, source=_make_source(), message_id="m1") + + +def _make_history(n_pairs: int = 3) -> list[dict[str, str]]: + h: list[dict[str, str]] = [] + for i in range(n_pairs): + h.append({"role": "user", "content": f"u{i}"}) + h.append({"role": "assistant", "content": f"a{i}"}) + return h + + +def _make_runner(history: list[dict[str, str]]): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.load_transcript.return_value = history + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + runner.session_store._save = MagicMock() + runner._session_db = None + return runner + + +@pytest.mark.asyncio +async def test_preview_reports_without_mutating(): + runner = _make_runner(_make_history(3)) + result = await runner._handle_compress_command(_make_event("/compress --preview")) + assert "no changes made" in result.lower() + assert "6 of 6" in result + runner.session_store.rewrite_transcript.assert_not_called() + runner.session_store.update_session.assert_not_called() + + +@pytest.mark.asyncio +async def test_dry_run_alias_matches_preview(): + runner = _make_runner(_make_history(3)) + result = await runner._handle_compress_command(_make_event("/compress --dry-run")) + assert "no changes made" in result.lower() + runner.session_store.rewrite_transcript.assert_not_called() + + +@pytest.mark.asyncio +async def test_preview_with_here_boundary(): + runner = _make_runner(_make_history(4)) + result = await runner._handle_compress_command( + _make_event("/compress --preview here 2") + ) + assert "last 2 exchange" in result + assert "4 of 8" in result + runner.session_store.rewrite_transcript.assert_not_called() + + +@pytest.mark.asyncio +async def test_aggressive_returns_unsupported_note_without_mutating(): + runner = _make_runner(_make_history(3)) + result = await runner._handle_compress_command( + _make_event("/compress --aggressive") + ) + assert "--aggressive is not supported" in result + runner.session_store.rewrite_transcript.assert_not_called() + + +@pytest.mark.asyncio +async def test_aggressive_dry_run_shows_preview_plus_note(): + runner = _make_runner(_make_history(3)) + result = await runner._handle_compress_command( + _make_event("/compress --aggressive --dry-run") + ) + assert "no changes made" in result.lower() + assert "--aggressive is not supported" in result + runner.session_store.rewrite_transcript.assert_not_called() + + +@pytest.mark.asyncio +async def test_preview_still_requires_enough_history(): + runner = _make_runner(_make_history(1)) # only 2 messages + result = await runner._handle_compress_command(_make_event("/compress --preview")) + assert "not enough" in result.lower()