Your plugin can finally notice a session being squashed or deleted

You ship a memory plugin that pushes your team’s coding conventions and three API endpoints into every turn. For twenty minutes it works beautifully. Then Hermes decides the conversation is too long and compacts it — every early message folded into one summary paragraph, your conventions included. From that moment the model keeps asking questions you already answered, and your plugin has no idea anything happened.
There is a quieter version of the same hole: you keep a small index file per session on disk. A colleague deletes forty sessions from the Desktop app, and your index stays exactly as fat as before — full of entries nobody will ever look up again.
Both problems have the same root: plugins could see “a tool was called” and “a turn started”, but not “the transcript was rewritten” or “this session no longer exists”. Two patches merged on October 18 close the gap — a brand-new post_compaction hook, and an on_session_delete hook that covers every delete entry point.
Why plugins could not see it before
The plugin system already had a lifecycle set: on_session_start, on_session_end, on_session_finalize, on_session_reset. Those mark points in time, not rewrites of content.
Compaction is an internal action. It can happen mid-turn when the context fills up, right after a giant tool result lands, or when the agent proactively trims stale tool output — or when a human types /compress. To a plugin, all of it was invisible. Session deletion is the same story from the other side: hermes sessions delete, single and bulk deletes from Desktop, /exit --delete, the empty-session sweep, retention pruning, ghost-row maintenance. They all end in the same database methods, but plugins got no callback.
So plugin authors did what people do with no signal: poll the session store, guess from token counts whether a compaction happened, scan directories for orphans. Slow at best, wrong at worst.
post_compaction: put back what the summary ate
Registration is the same as any other hook:
def register(ctx):
ctx.register_hook("post_compaction", on_compaction)
def on_compaction(session_id, kind, reason, **kwargs):
# flag it, then re-inject from the next pre_llm_call
mark_session_for_reinjection(session_id, kwargs.get("messages_removed"))
What the callback receives:
| Field | Meaning |
|---|---|
session_id |
The session holding the compacted transcript now |
old_session_id |
The session it started in when the compaction rotated; empty string when compacted in place |
kind |
summarize (full compaction), micro_summarize, prune_tool_results, provider_native (Codex app-server) |
reason |
What triggered it: manual (/compress), pre_api, post_tool, overflow, gateway_hygiene, between_turns, proactive_prune, provider, unknown |
before_tokens / after_tokens |
Rough token estimates, None when unknown |
messages_removed |
How many messages went, None when unknown |
The important part is that it fires once for every compaction that actually lands, on every path: CLI, TUI and Desktop backends, gateway agents, cron sessions, the gateway’s /compress, Codex-native compaction, micro-compaction and proactive tool-result pruning. Attempts that changed nothing — an aborted summary, one blocked by a cooldown, one refused because it would grow the transcript, an empty /compress — fire nothing, so you never have to second-guess whether a “compaction” was a no-op.
A pattern that actually works: record that this session needs a refresh, then re-inject from the next pre_llm_call. The hook itself is an observer — its return value is ignored and you cannot rewrite the conversation from inside it. That is deliberate: the compaction is already committed, and changing it mid-flight would break consistency.
_pending = {}
def on_compaction(session_id, kind, reason, messages_removed=None, **kw):
if kind in ("summarize", "micro_summarize"):
_pending[session_id] = {"kind": kind, "removed": messages_removed}
def inject(session_id, conversation_history, **kw):
if session_id in _pending:
_pending.pop(session_id)
return "Project rules: every new endpoint needs OpenAPI annotations; migrations go through alembic only."
return None
def register(ctx):
ctx.register_hook("post_compaction", on_compaction)
ctx.register_hook("pre_llm_call", inject)
on_session_delete: balance your own books
The second hook handles cleanup. It fires once, after the delete or deliberate archive commits:
| Parameter | Meaning |
|---|---|
session_id |
The session the operation targeted |
reason |
delete, archive, empty_sweep, prune, ghost_prune, profile_repair |
profile |
The profile owning the store (default for the root home) |
deleted_ids |
Every removed row (cascade children, compression-chain links, bulk rows) when more than one went |
sessions_dir |
The profile’s sessions/ dir when the caller had one |
def on_delete(session_id, reason, profile=None, deleted_ids=None, sessions_dir=None, **kw):
ids = deleted_ids or [session_id]
for sid in ids:
drop_sidecar(sid) # your per-session cache / index
log.info("cleaned %d session rows (%s)", len(ids), reason)
def register(ctx):
ctx.register_hook("on_session_delete", on_delete)
Two details in that patch are easy to miss and worth knowing.
First, Desktop never fired before. Desktop and dashboard single deletes and archives do not go through SessionDB.delete_session — they go through a runtime-session receipt path, so the older wiring was silent there. That path now fires too, and an exact retry replays the receipt without emitting a second event.
Second, archiving counts. Archiving means the user hid the session; the transcript stays and can be un-archived. For a plugin that usually means “this thread is over” — if you were tracking a task, a user archiving the chat is a good moment to close it. Only a real unarchived → archived flip fires, so idle auto-archive sweeps and profile adoption stay quiet.
A third thing fixed in the same window: slash commands know their session
One related change deserves a mention. A plugin slash command registered with ctx.register_command used to receive only the raw argument string — no idea who ran it, on which platform, in which session. Now a handler that declares these keywords gets them:
def _handle_whoami(raw_args, *, session_id="", platform="", source=None):
user = source.user_id if source is not None else "local user"
return f"{user} on {platform}, session {session_id or '(none yet)'}"
def register(ctx):
ctx.register_command("whoami", handler=_handle_whoami, description="Show the calling session")
The available keywords are session_id, session_key, platform and source (the adapter-derived identity object with user_id, chat_id, thread_id and friends) — or just declare **kwargs and take all of them. Existing plugins need zero changes: a one-argument handler is still called as handler(raw_args), and async handlers are still awaited.
One security note: source is identity information, not an authorization grant. A sensitive plugin command should decide from stable ids (user_id, chat_id) and fail closed when source is None.
Three boundaries to design around
- Both are observers. Return values are ignored; a callback that raises is logged and skipped. The delete or compaction is already committed — a plugin can neither veto it nor expect a rollback.
- Callbacks are time-bounded by
plugins.hook_callback_timeout(30 seconds by default). If you make network calls inside a hook, set your own timeout; the thing that stalls is your callback, not the conversation. - Delivery is process-local, with no exactly-once guarantee. The delete event fires only in the process that committed the delete. External side effects driven from the callback should tolerate a missed or a duplicated call.
When you can use it
Both hooks merged to the main branch on October 18, after the latest v0.22.2 release on October 15 — so they live on main only and are in no published version yet. If you installed Hermes from PyPI or an update channel, you cannot use them today; running the main branch from source is the way to try them early.
For the full hook list and exact firing conditions, the official Event Hooks page is canonical; the site’s plugins command reference covers the CLI surface. If you are building in this area, two earlier posts pair well with this one: the plugin token broker shows how a plugin signs a user into a third-party API without ever holding the token, and compaction prompt rebuild explains how the prompts are reassembled during compaction. Writing a memory plugin? Dreaming-style memory consolidation takes the other route — tidying up while idle instead of reacting to compaction.
The short version
post_compaction answers “what do I do when my injected context gets summarized away”. on_session_delete answers “who cleans up my mess when the session is gone”. Neither changes what Hermes does; they simply expose two moments that used to be internal. For plugin authors, that is a whole class of polling, guessing and defensive fallbacks you can now delete.