feat(commands): /compact alias + --preview/--dry-run flags for /compress (#3243 salvage)

Salvaged from PR #3243 by @Mibayy, reimplemented against current main
(the original diff targeted a removed gateway/run.py handler).

- /compact is now a first-class alias of /compress (CLI, gateway,
  Telegram/Slack/Discord command lists, autocomplete) — also fixes the
  dangling '/compact' references in gateway error messages
  (gateway/run.py context-exhausted banners).
- --preview / --dry-run: report what WOULD be compressed (message
  counts, token estimate, 'here [N]' boundary) without touching the
  transcript. Flags coexist with the existing 'here [N]' / focus-topic
  args on both the CLI and gateway surfaces via shared pure helpers in
  hermes_cli/partial_compress.py.
- --aggressive (LLM-free hard truncation) is intentionally NOT
  implemented: it would need its own transcript-persistence branch
  outside the guarded _compress_context rotation machinery (#44794
  data-loss class). The flag is recognized and returns an explanatory
  message pointing at '/compress here [N]' and /undo instead of being
  mis-parsed as a focus topic.
- locales: gateway.compress.aggressive_unsupported added to all 16
  catalogs (parity test enforced).
- release.py: AUTHOR_MAP entry for contributor credit.
This commit is contained in:
Mibayy 2026-07-02 04:47:47 -07:00 committed by Teknium
parent fb74ddf7fe
commit ce9aa869fc
23 changed files with 449 additions and 2 deletions

35
cli.py
View file

@ -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:

View file

@ -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

View file

@ -88,8 +88,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="<platform>", 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",

View file

@ -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:

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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 モデル設定を確認してください。"

View file

@ -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 모델 설정을 확인하세요."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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 模型設定。"

View file

@ -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 模型配置。"

View file

@ -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)

View file

@ -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

View file

@ -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()