Metadata-Version: 2.4
Name: protagine-hermes
Version: 1.11.0
Summary: Protagine adapter and memory provider for stock Hermes
License-Expression: MIT
Project-URL: Homepage, https://aevonix.com/protagine
Project-URL: Repository, https://github.com/Aevonix/Protagine
Requires-Python: <3.14,>=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<0.29,>=0.27
Requires-Dist: typer<1,>=0.12
Requires-Dist: PyYAML<7,>=6
Provides-Extra: test
Requires-Dist: build>=1.2; extra == "test"
Requires-Dist: pytest>=8; extra == "test"
Requires-Dist: setuptools>=77; extra == "test"
Requires-Dist: wheel; extra == "test"
Provides-Extra: quality
Requires-Dist: ruff<1,>=0.13; extra == "quality"
Requires-Dist: twine<8,>=6; extra == "quality"
Provides-Extra: native-memory
Requires-Dist: nemo-relay<0.9,>=0.8.3; extra == "native-memory"
Dynamic: license-file

# Hermes adapter

`protagine-hermes` is one wheel with two Hermes plugins:

- `protagine` (general): turn capture, the guard, `/mind`, and the self, people,
  opinions, memory and reminder tools
- `protagine-memory` (memory provider): per-turn recall through
  `/v1/host/context/assemble`, turn sync when the general plugin is absent, and
  the owner's commitment and affect writes

Both run on stock Hermes (`hermes-agent >= 0.21.3, < 0.22`) with no patches.

## Install

```bash
pipx install protagine && protagine init && hermes gateway restart
```

`protagine init` installs `protagine-hermes` into the Python of the `hermes`
executable, writes the config keys below and creates the `protagine-act`
worker profile. `protagine upgrade` repeats the adapter install when the
version changed.

```yaml
plugins:
  enabled: [protagine]
  hook_callback_timeout: 0          # callbacks run inline; overlapping fires are not dropped
  protagine:
    sidecar_url: http://127.0.0.1:7777
    key_file: ~/.protagine/api.key  # one line, mode 600
memory:
  provider: protagine-memory
kanban:
  dispatch_in_gateway: true         # the plugin's heartbeat is the dispatch tick
skills:
  external_dirs: [~/.protagine/skills]   # Protagine's own skills (with faculties.skills on); nothing else writes there
security:
  protected_instruction_extra_patterns: [protagine.yaml, identity.yaml, api.key]
```

Every request to the sidecar carries `Authorization: Bearer <key>`. The key is bound to the contacts and
channels it may write as ([CREDENTIALS.md](CREDENTIALS.md)): it writes a turn as the owner only from the
platforms `protagine.yaml` `credentials.api-key.owner` lists (by default WhatsApp, SMS, RCS and voice).

## Seams used

| Need | Stock seam |
|---|---|
| Who is speaking | `pre_llm_call` (`session_id`, `platform`, `sender_id`, `user_message`), kept in a bounded session map |
| Turn capture | `post_llm_call` enqueues one row in `<hermes_home>/state/protagine-turn-outbox.sqlite3`; the body thread delivers it to `POST /v1/host/turns/sync` |
| Recall | memory provider `prefetch` |
| Guard | `pre_tool_call`; a block directive fails closed, a floor match becomes an `approve` directive (Hermes' human-approval gate, which fails closed in a worker) |
| Heartbeat | `on_kanban_dispatch_tick` wakes the body thread; stock fires it from `dispatch_once` in the one process holding the dispatcher lock, so it also marks this process as the board's writer |
| Pause | `agent.estop.is_engaged()` (`hermes pause`): the body creates and sends nothing while it holds |
| DM lookup | the stock session rows (`hermes_state_registry`, `list_gateway_sessions`): the DM chat the gateway has had with a sender, for handles that name a user rather than a chat (Discord) |
| Durable work | `hermes_cli.kanban_db.create_task(idempotency_key="mind:<id>", assignee="protagine-act", ...)`; an existing non-archived task with the key is returned, never duplicated |
| Messages | `tools.send_message_tool.send_message_tool({"action": "send", "target", "message"})`, text verbatim |
| Task outcomes | the `mind:*` task's status plus its latest `task_runs` row (`kanban_db.latest_run`) |
| Owner commands | `register_command("mind")`: read-only in chat plus `off`; `pre_gateway_dispatch` drops a `/mind` whose sender is not the owner, since the command handler gets no sender |
| Tools | `register_tool`; handlers receive `session_id`, which the session map resolves to a sender |
| Reminders | stock cron: a one-shot job whose id is the only thing the plugin keeps |
| Skill loads | `on_skill_lifecycle`: a `loaded` fact for a `protagine-*` skill is queued in the turn outbox as a `skill_use` row, which the body delivers to `POST /v1/mind/skills/used`; every other skill and action is Hermes' own |
| Skills index | `skills.external_dirs` lists `<instance>/skills`; when `/v1/mind/state` reports a new `skills.generation`, the body calls `agent.prompt_builder.clear_skills_system_prompt_cache()` so the next session lists the change (stock caches the index per process with no file times in its key); without that function a change shows after a restart |
| Secrets | a second `pre_gateway_dispatch` seals a gateway message's secrets (and the quoted message and backfill that come with it) before Hermes sees them; `register_middleware("tool_request")` serves the owner's sealed values to his own calls (into a send's or a write's words, or a command's or code's environment for that call) or hides them in a worker's; a second `pre_tool_call` holds a call whose value waits for the owner or cannot be used there; `post_tool_call` removes a call's private value files unless the program that took one still runs; `register_redaction_patterns` and `agent.redact.register_vault_redaction_value` keep used values out of model-facing output; `protagine_secret` (see Secrets) |

## Owner and guest

The owner is the sender whose handle is listed in `identity.yaml`
(`owner.handles.<platform>`) or whose resolved contact is `owner.contact_id`.
Turns without a sender on the CLI, cron or API lanes are the owner's own for
the plugin's tools; a cron run (`platform: cron`) is a stored prompt rather than the
owner typing, so it cannot answer an ask, rate, change a permission or
forget. In memory a turn is the owner's only when it arrives on a platform the key
is granted the owner on: every turn names its platform (the sender's, the lane
itself: `cli`, `cron`, `api`, or the platform the memory provider was initialized
on when no session binds one). A turn the sidecar refuses (`owner_not_granted`) is
never sent again as another contact: the adapter logs it and counts it as a failed
capture (the outbox row keeps the refusal and `/mind status` counts it; the memory
provider's diagnostics count `attribution_refusals`). Grant the CLI with
`protagine init --owner-platform ...` or `protagine credentials set api-key
--owner-platform ...` to keep a CLI conversation as the owner's. Any other sender is a guest: `session_search` is blocked for guests,
delivering cron jobs need a recipient whose `may_contact` is not `never`,
every mutation through `protagine_self`, `protagine_people` and
`protagine_memory_forget` is refused, `protagine_people who` without a name is
refused (a guest may ask who one named person is, never list the contacts), and
the plugin drops a guest's `/mind` at `pre_gateway_dispatch`, because Hermes
hands the command itself no sender.

## Mind-originated runs

A kanban worker spawned on the `protagine-act` profile is a mind-originated
run (the dispatcher sets `HERMES_KANBAN_TASK` and `HERMES_PROFILE`). In it:

- `kanban_create` only with `assignee: protagine-act`, no `mind:` key, no
  project override and no workspace outside `HERMES_KANBAN_WORKSPACE`
- `write_file` and `patch` only inside `HERMES_KANBAN_WORKSPACE`, every V4A
  header target included
- `mind.deny.tools` and `mind.deny.text` from `protagine.yaml` block; floor
  patterns (money, irreversible deletion, credentials, bulk messaging) ask
- messaging tools and delivering cron jobs need a permitted recipient: the
  plugin resolves a job's effective `deliver` and `failure_deliver` contacts
  (a stored job's when an update does not resend them) and hands them to the
  sidecar as `recipients`, which applies `may_contact` and the message budgets
  to each of them exactly as it does to a `send_message` recipient
- with `mind.enabled: false` or `autonomy: off`, every effectful tool is blocked

The guard asks `POST /v1/mind/guard {tool, args, session, run, task_id, owner,
contact_id, platform, sender_id, recipients}` with a 2 s timeout when the
sidecar serves the mind routes and reads `{allow, reason}` (`ask: true` becomes
Hermes' approval gate in the owner's and the mind's runs); silence blocks effectful tools, a 404 falls back
to the rules above. Read-only tools never wait on the sidecar.

In a contact's (non-owner) session nothing becomes Hermes' approval gate: in a gateway that gate posts its
prompt to the session's own chat and waits for `/approve` or a bare "yes" from it, so the contact would be
the one approving. There an `ask` verdict is refused, and every refusal is the plugin's one final answer
(`retry: false`), worded for a reply the contact reads. The final response is delivered to the session's
own chat and nowhere else: the chat the gateway bound for the turn (a group's chat in a group), or, with no
gateway, the sender's direct chat. A message to that chat is answered with "your final response is
delivered to this conversation as your reply": the reply already is that message. Anything else, another
handle of the sender's and a direct message to the sender from a group included, is a recipient for the
sidecar's verdict; a target no contact is known at is answered "nothing was sent: no contact is known at
that target".

## The body: the mind's effects on Hermes

The body thread runs in every Hermes process, but only the one that owns the
kanban dispatcher writes to the board: stock fires `on_kanban_dispatch_tick`
from `dispatch_once` in the process holding the singleton dispatcher lock, and
the body runs steps 2 to 5 only after it has seen that tick. A CLI session, a
gateway that lost the lock and a kanban worker drain the turn outbox only; the
first two also read `GET /v1/mind/state` on every tick for the skills
generation (step 1), since Hermes caches the skills index per process. While
`hermes pause` holds (the stock ESTOP sentinel) steps 2 and 3 wait as well. Once
the sidecar serves `/v1/mind/*`, the body does this on every tick (about every
60 s, and at once when the dispatch tick or a captured turn wakes it):

1. `GET /v1/mind/state`. With `enabled: false` there, or `mind.enabled: false`
   in `protagine.yaml`, steps 2 and 3 are skipped and every unstarted `mind:*`
   task is archived; running workers end on their own. None of this needs a
   model endpoint. When `skills.generation` moved since the last tick (the
   sidecar wrote or removed one of its skills), Hermes' skills prompt cache is
   cleared; the first value a process sees is only recorded.
2. `GET /v1/mind/dispatch` → for each `kind: task` intention one
   `kanban_db.create_task(title, body, assignee="protagine-act",
   idempotency_key=dedup_key or "mind:<id>", workspace_kind="scratch",
   created_by="protagine", priority, max_runtime_seconds, max_retries,
   goal_mode, goal_max_turns)`, then `POST /v1/mind/dispatch/{id}/bound
   {hermes_ref, hermes_kind: "kanban", status, bound_at}`. A lost ack or a
   restart creates no second task: the key returns the existing one, and a
   task with the key that was archived in between (stock's lookup skips
   archived tasks) is bound and settled as `cancelled` rather than recreated.
   Messages and notices never travel through dispatch. Runtime and retries
   are the task kind's (`mind.budgets.task_types`, else
   `task_max_runtime_s` / `task_max_retries`), and fall back to that flat
   pair when a payload carries none.
3. `GET /v1/mind/outbox` → for each message, in order: skip it if the body's
   own ledger has begun it before; `POST /v1/mind/outbox/{id}/sending {target,
   at}` (any non-2xx: not ours); record `sending` locally; send **verbatim**
   with `send_message_tool`; record the result; `POST
   /v1/mind/outbox/{id}/sent {result: sent|failed|uncertain, error, hermes_ref,
   at}`. `success` from the tool is `sent`; an error raised before the platform
   call (unknown target, unconfigured platform, no home channel) is `failed`;
   `Send failed: ...` or an exception is `uncertain`. A
   process that stops between `sending` and `sent` reports that message
   `uncertain` at the next start and never sends it, whatever the sidecar
   lists. The target is the message's `target` (`platform:chat_id`), else
   `recipient.platform` + `recipient.chat_id|address|handle`, else, for the
   owner (`recipient_is_owner`, the owner contact or no recipient), the first
   handle in `identity.yaml` `owner.handles` (the `[{platform, id}]` list
   `protagine init` writes, or a `{platform: [ids]}` mapping), else the first of
   the sidecar's `recipient_handles` (`{gateway, address}`, primary first). A
   handle names a sender; where that is not a chat (a Discord user id) the
   target is the DM session stock has recorded with that user, and the handle
   itself when none has been seen yet.
   The sidecar answers `sending` with 409 for a message it already handed
   out, and marks a claim nobody settled `uncertain` after ten minutes. A
   claim that names no target (`target: null`, the body resolved no handle)
   is also 409 (`no_target`) and is not a claim: the message stays ready,
   never `failed`, and the next pull offers it again with `recipient_handles`
   read afresh, so a handle added later lets it go out.
4. Reconciliation: for every `mind:*` task, `GET /v1/mind/why/{id}`. A 404, or
   a lifecycle `status` in `proposed | asked | denied | expired | cancelled |
   dropped`, means no dispatched intention owns the task and it is archived
   (the ask backstop of architecture 7.7). Otherwise, when the task is
   `done | blocked | review | archived` or its latest run has ended, one
   `POST /v1/mind/outcome {id, hermes_ref, hermes_kind, status, outcome:
   done|blocked|failed|cancelled|uncertain, final, summary, error, verified:
   "hermes_failure"|null (the sidecar honours the claim only for a failure
   with a reason, and never takes `owner` or `check` from the body),
   run: {id, outcome, status, profile, started_at,
   ended_at}, block_kind, consecutive_failures, completed_at, observed_at}` per
   state change (a failed run that Hermes requeues is one `failed`, `final:
   false` outcome; a task parked `blocked` with its failed run once
   `consecutive_failures` reached `max_retries` is `failed`, `final: true`; a
   `kanban_block` is `blocked`; a 404 archives the task). The body remembers
   what it posted in `<hermes_home>/state/protagine-body.sqlite3` so an outcome
   is posted once.
5. `POST /v1/mind/observations {observed_at, board, body: {pid, profile,
   started_at, interval_s, ticks, mind_ticks, last_tick_at, last_mind_tick_at,
   last_pull_at, stale}, counts: {status: n}, stale_tasks, blocked_tasks,
   goals, mind_tasks}` when the board changed and at least every 5 minutes.
   Task entries carry `id, title, status, assignee, created_by, created_at,
   started_at, age_s, idle_s, block_kind, goal`; a stale task is an open
   non-mind task idle for `mind.stale_task_hours` (default 72); goals are
   kanban goal-mode tasks; `mind_tasks` add `intention_id` and the latest run.

Every `GET /v1/mind/dispatch` is the body's heartbeat: a sidecar whose last
pull is older than 5 minutes stops deliberating (architecture 3.1).

`protagine_hermes.tick()` runs one full tick on the caller's thread: `POST
/v1/mind/tick` (the sidecar forms intentions now) and then the body pass above.
The paired benchmark's body tick calls it before Hermes cron and kanban
dispatch, with `PROTAGINE_BODY_THREAD=0` so the body's own thread stays
parked and nothing lands between two observed ticks; a gateway never needs
either.

## Asks

An ask lives only in the sidecar and creates nothing in Hermes until approved.
The owner answers with `protagine mind yes|no <code>`, or in chat by typing
the code ("yes K7F"): the model calls `protagine_self` `yes`/`no` and the
plugin sends `POST /v1/mind/decide {code, answer, session_id, contact_id,
message}` only when the session's sender is the owner, the turn is not a kanban
worker, and the owner's own message for that turn contains the code as a whole
word; the sidecar checks the contact and the message again and answers
`{ok, id, status, ...}` (404: no open ask with that code, 403: not the owner). A code the owner's message
does not carry is never a guess away: with no ask open, or none typed, the answer is final; a miscopied code
on a message that carries an open ask's code names that code.
A guest, a worker whose task body quotes the code, or a page injected into an
owner session cannot approve. `protagine_self rate {id, verdict}` posts
`POST /v1/mind/rate` for the owner only. `log` and `why` are the owner's too,
and a guest's `state` says only whether the mind is on and at what level: the
log and the asks name what the owner asked about other people.

## People

`protagine_people` is the model's view of the people store over `/v1/mind/people`
(the sidecar side is `sidecar/protagine/api/routers/people.py`):

| Operation | Who | Route |
|---|---|---|
| `who {contact_id}` | everyone (listing: the owner) | `GET /v1/mind/people?q=` (a name, handle or id; empty lists the newest, for the owner only) |
| `inspect {contact_id}` | everyone | `GET /v1/mind/people/{who}` |
| `propose_link {contact_id, handle: gateway:address}` | everyone | `POST /v1/mind/people/link`: a candidate the owner confirms as an ask |
| `set_permission {contact_id, permission}` | owner | `POST /v1/mind/people/{who}/permission {may_contact: never\|ask\|auto}` |
| `set_cadence {contact_id, minutes}` | owner | `POST /v1/mind/people/{who}/cadence {minutes}` (0 clears) |
| `merge {contact_id, drop}` | owner | `POST /v1/mind/people/merge {keep, drop}`: `drop` folds into `contact_id`; handles, sources, comms and affect follow the person |

A guest sees who someone is (`contact_id`, `display_name`, `trust_tier`) and
nothing else: the plugin names the guest as the viewer (`contact_id`) on its
reads and the sidecar answers with only those fields, and only for the one
person the reference names exactly (no listing, no partial matches, no 404
candidates), so a guest cannot browse the contact list. The owner also sees
`may_contact`, `cadence_minutes`, `last_interaction_at`, the per-contact
`digest` and the handles. The three mutations are refused in the plugin
outside the owner's own interactive session (a guest, a kanban worker, a cron
run) and are sent with the owner as `contact_id`, which the sidecar checks
again (403 `not_owner`). `protagine people who|inspect|permit|cadence|merge|link|proposals`
is the same interface from the CLI. `may_contact` is raised nowhere else; a
contact's opt-out ("STOP", "don't text me", ...) only lowers it to `never`.

`protagine_self` also carries the agent's recorded opinions (one tool, not a
separate one, so the tool schemas stay within their budget): `opinions` with an
optional `query` and `why <number>` read `GET /v1/mind/opinions` with the session's
contact, so a guest sees only everyone-audience views (an intention id is never all
digits, so `why` on any other id is still the owner's record); `withdraw` and
`reconsider <number>` with the owner's `reason` post
`POST /v1/mind/opinions/{id}/withdraw|reconsider` from the owner's own interactive
session only, never a guest, a kanban worker or a cron run (docs/OPINIONS.md). The
guard treats the tool as read-only.

## Secrets

A secret that arrives in conversation is sealed into the sidecar's store and a reference stands in its place
(docs/SECRETS.md). The plugin's half (`secrets.py`):

- `pre_gateway_dispatch`, after the `/mind` gate: when the plugin's copy of the detector (byte for byte the
  sidecar's) finds a value in a gateway message, it posts `POST /v1/secrets/seal {text, owner | contact_id}` and
  returns `{"action": "rewrite", "text"}`; Hermes, its transcript and the model see the reference. The owner is
  named as such (the sidecar scopes the value to its owner), anyone else by their resolved contact or, when no
  contact has the handle, `handle:<platform>:<id>` (never created). The event's other words that Hermes puts in
  front of the model are sealed in place the same way: the quoted message (`reply_to_text`, in its author's
  scope) and a channel's backfill (`channel_context`, in nobody's). Without the sidecar each value is withheld; a
  value the sidecar returns as it came passes only in a span its answer marks as released.
  `secrets.capture.ingress: false` turns this off; a message with no detected value is never sent there. The
  section is validated as the sidecar validates it: a malformed one leaves every rule on.
- `tool_request` middleware: in the owner's own session a call carrying a reference has the value served
  (`POST /v1/secrets/resolve {purpose: fill, lane: owner, own_refs, action, destination}`, 32 references a
  request, more in turns) when the reference was sealed from the owner's own words in his latest message in that
  same chat (`own_refs`: what the gateway hook sealed from his text, never from a quote, a backfill, an expanded
  file or another chat); otherwise the sidecar forms a one-tap ask bound to the digest of the complete call, the
  plugin puts it to the owner in his chat directly, and the second `pre_tool_call` hook holds the call with its
  words. A value goes as text only into a send's or a write's words (`message`, `content`, `new_string` and the
  like) and into a waiting process's input as the whole of it. A terminal command or Python code never carries a
  value: it names `$PROTAGINE_SECRET_<id>` (the value) or `$PROTAGINE_SECRET_<id>_FILE` (a private 0600 file holding
  it), and the middleware hands the command to a subshell that sets both from the file (`bind_shell`) or runs the
  code with both in the interpreter's environment until it ends (`bind_python`); the command or code is never read
  or changed (a command follows the lines that set its values as it was written, so Hermes still reads its words,
  a `sudo` included; code goes as one string literal). A reference written into a command or code is refused with
  the variable to name, and a command or code naming a variable the plugin did not set for it is held. Values
  reach commands and code on the local backend only. The program that takes a file records its process and
  removes the file when it ends; `post_tool_call` removes a call's files when no program holds them, and a sweep
  removes those whose program is gone or that none took within an hour. `write_file` and `patch` in his session
  are served without an ask; a reference where no value goes (a patch's `old_string`) holds the call. In a mind
  run a worker's message to anyone but the owner has every detected value and reference replaced by
  "(a private value, withheld)".
- The body resolves an outbox message's references just before it claims and sends it (`purpose: send`, in
  batches of 32); the sidecar allows only the owner's own values, to the owner or in his dictated words, and the
  body hides any other for a contact. When any reference is not settled for now (the sidecar unreachable or without
  an answer for it, the key unavailable) the message waits for a later tick: nothing is claimed or sent.
- `protagine_secret` (`list`, `use`, `reveal`, `release`, `forget`) works in the owner's own direct session only;
  a guest, a cron run and a worker are refused. `reveal` sends the value to the owner's own chat itself (Hermes
  would mask it in a reply) and tells the model only that it did; in a session with no chat it writes it to a
  private file (0600, removed within half an hour) and tells the model only where it is.
- A delivered turn-outbox row keeps its turn id and envelope digest and drops its payload; a rejected one
  (`last_error` "rejected: ...") keeps it in the private outbox file, the turn's only copy, for repair.

## Memory provider

`prefetch` assembles context for the turn's participant. A guest request sets
`audience: viewer`, and the sidecar returns a guest only that contact's scoped
sections, never an owner-only one. A kanban worker's prefetch names its task
(`context.metadata.kanban_task`): its task body already carries its lessons, so
the sidecar adds no turn lesson and logs no lesson use for it. A scheduled job's
prefetch (Hermes builds its agent with platform `cron`) says so
(`context.metadata.run: cron`): a job's prompt is not the owner's turn, so the
sidecar leaves the owner's status block out of it. Hermes waits
for the whole prefetch, so each one has one deadline from its start (Hermes'
external prefetch bound less the provider's 0.5 s margin): the participant's
resolution, the assembly and the Current Time refresh all end by it, each
bounded in wall-clock time as a whole (an HTTP client's timeout bounds each
read, so an answer arriving in slow chunks would outlast it): past the
deadline the provider stops waiting and closes that request's connection. The
assembly request names what is left (`wait_ms`), the sidecar's recall spends
only that (docs/RECALL-HYBRID.md, Recall latency), and the Current Time refresh
gets what the assembly leaves (every refresh while sections are held back
included); with nothing left the turn carries the provider's own clock line. The scoping
fails closed: anyone the sidecar cannot show to be the owner, a caller without
the key included, gets the contact-scoped set. Recall includes this session's
own earlier turns: Hermes compacts sessions in place, rebuilds agents after an
idle eviction or a restart and compacts on detached agents, so no provider
instance knows whether the model still reads them verbatim. The provider's
direct tools are offered on the owner's own lane only: a guest session or a
channel with no sender binding gets none of them, and a call that still
arrives, like `protagine_memory_search` for a turn with no resolved
participant, is answered once with `{"unavailable": true, "retry": false,
"reason": ...}` rather than an error the model retries. `sync_turn` is active
only when the general plugin is not enabled; otherwise the outbox owns capture.
`on_pre_compress` writes a checkpoint through the same outbox before Hermes
compresses a session.

Hermes appends each turn's recalled context to that turn's user message and
replays it byte for byte on every later turn (the `api_content` sidecar). The
provider keeps a fingerprint of each context section it showed, per session, and
a section unchanged since the session's last shown turn is one line in the new
turn's context: `unchanged since your last turn: <section>`. Only the new turn
changes; earlier turns are never touched, so the cached prompt prefix holds.
Current Time is always sent. A prefetch counts as shown only once its turn
completed (`sync_turn`, called whoever writes the turn), only when Hermes waited
for it (its 8 s external prefetch bound) and only when it went inline whole
(under Hermes' spill cap). The fingerprints reset on every session switch
(including `/undo`), before compression (`on_pre_compress`) and on restart; the
gateway's detached compaction evicts the live agent, whose provider starts
empty.

The context never outgrows Hermes' inline cap (`hooks.output_spill.max_chars`,
10,000 by default), which would replace the whole of it with a head, a tail
and a file path. The provider names its inline allowance (`inline_chars`, the
cap less 256 for the clock it adds) and the sections it will send as one line
(`shown`, their fingerprints); the sidecar's per-turn selection fits the
context to that allowance, and the turn's resident blocks are fitted to the
same budget: a hard limit per block, fresh state admitted first, whole lines kept
and a marker for what was held back, and a `Held back this turn` section
naming any block that did not fit (`sidecar/protagine/memory/blocks.py`,
architecture 4.1). Against a sidecar that fits nothing (1.10) the provider
holds whole sections back itself, fresh state last, and names each one. With
`context.blocks: false` the sidecar reports `{"blocks": false}` and the
provider holds nothing back either: the owner chose the 1.10 loader.

## Tests

`tests/hermes_adapter` runs against stock Hermes installed in the same
environment plus a fake sidecar; see `tests/hermes_adapter/conftest.py`.
