Read Once 0.2
A native Hermes plugin. When a model repeats an identical read_file call, Read
Once can return a short note addressed to the model. The model then answers
from the earlier result. The user still receives that answer. Read Once does
this only when all of these hold:
- Hits are explicitly enabled (
READ_ONCE_HITS=1). - The file bytes are unchanged. The plugin re-reads and re-hashes the file
immediately before every hit. - The exact result that was delivered earlier is still visible in the most
recent model request for the same session, task, and context epoch.
By default the plugin is observe-only: it records counts and never
substitutes anything.
The plugin is Python stdlib only and does not monkey-patch anything. It holds
state in process memory only: digests, ids, lengths, epochs, and visibility
flags. It never stores document or prompt text, writes no disk cache, makes no
network calls, and runs no background watcher.
The plugin does not open or hash a file whose name is a dotenv name (.env,
.env.local, .envrc, and the other .env.* names Hermes denies), auth.json,
auth.lock, .anthropic_oauth.json, webhook_subscriptions.json,
google_oauth.json, or a Bitwarden/1Password/Photon cache file, or any file
whose path has a vault, mcp-tokens, browser-profile, webapp, or
pairing component. Those reads pass straight through to Hermes. This is a
fixed name list. It does not mean the plugin never reads every file Hermes's
own denylist might refuse.
The note is for the model
A hit is not a message for the user. The tool result is a note that names the
earlier read_file call, the path, and the first 12 hex digits of the file's
SHA-256. It tells the model that the full content is already in the conversation,
and to answer the user from that content. It tells the model not to quote the
note and not to read the file again unless the user says it changed.
The note does not suggest another line range. It does not describe itself as a
stub. It does not start with a [read-once] tag.
A second identical read in the same model turn, immediately after that note,
calls Hermes once. That stops a loop if the model ignores the note. A later
turn, while the entry is still visible and the bytes are unchanged, receives
the note again.
Read Once does not store a Hermes unchanged-stub, and it does not store a
truncated page. A different offset or limit is a different key, so that read
passes through.
Support surface
- Tool:
read_fileonly, with argumentspath(required, absolute),
offset(default 1), andlimit(default 2000, the Hermes 0.23read_filedefault). The effective offset and
limit are part of the cache key, so an explicitoffset=1, limit=2000matches
a call that omits them. - Files: regular files only, not symlinks, at most 1 MiB, and resolved
(realpath) to a location strictly underREAD_ONCE_ROOT. - Scope: profile (if supplied),
session_id, andtask_id. Scopes never share
entries. Each scope holds at most 128 entries, and the oldest is evicted
first. At most 256 scopes are kept, also evicting the oldest. - Backend: local only. See
non_local_backendbelow. - Hooks:
post_compaction,on_session_reset,on_session_finalize, and
on_session_deletewipe entries. A hook with a session id clears that
session and increments its epoch. The ids aresession_id,
old_session_id, and every id inon_session_delete'sdeleted_ids.
A hook with no session id clears all sessions and increments the global
epoch. A malformeddeleted_idsalso clears all sessions.on_session_endis turn-scoped, so it only counts and does not wipe.post_tool_callonly counts.
Configuration
| Variable | Effect |
| --- | --- |
| READ_ONCE_ROOT | Directory that eligible files must resolve under. If unset, the plugin never substitutes. |
| READ_ONCE_HITS | Must be exactly 1 to allow substitution. Any other value, or unset, means observe-only. |
| READ_ONCE_NOTICE | off (default), quiet, or verbose. Any other value is off. Hermes does not expose a dim status line. PluginContext.post_message would insert a conversation message, so Read Once does not post one. The approximate count is only in /read-once stats. |
All three variables are read on every call. READ_ONCE_NOTICE does not change the tool result.
How a hit happens
- First read (miss). The plugin hashes the file, calls
next_call
exactly once, then reads the file again through the same root fd walk. If
both hashes match, the backend is still local, and the result is exactly
the requested window of those bytes, it stores the SHA-256 of the result
as a candidate. - Model request (
llm_execution). For the same session, task, and
profile, and the current epoch, the plugin checks whether the request
contains the exact result string as one string leaf. An entry is visible
only if it appears in the latest request, so a later request without it
clears the flag. A request with a session id but a missing or empty
task_id, or with a parent id, clears visibility for that whole session.
The request is passed tonext_callunchanged, exactly once, and the
response is returned unchanged. - Repeat read. The plugin checks the scope, key, and epoch, confirms the
entry is visible, and confirms no refresh is pending for the path or its
inode. It then re-reads and re-hashes the file and compares SHA-256, size,
mtime_ns,st_dev, andst_ino. If everything matches and hits are
enabled, it returns the model note without callingnext_call. Another
repeat in a later turn does the same. An immediate repeat in the same
turn_idcallsnext_callonce.
Bypass list (always passes through to Hermes)
| Reason | Meaning |
| --- | --- |
| root_unset, root_invalid | READ_ONCE_ROOT is missing or not a directory |
| relative_path | Path is not absolute. The plugin cannot know Hermes' working directory. |
| outside_root | The resolved path is not under the root, including escapes through symlinked directories |
| symlink, not_regular, too_large | Not an eligible file |
| stat_failed, open_failed, hash_failed, changed_during_hash | The file could not be checked reliably |
| unknown_arg, bad_args | Any argument key other than path, offset, or limit, or values that are not positive ints |
| missing_identity | session_id or task_id is missing or empty |
| parent_id | parent_session_id or parent_task_id is present. v1 does no parent/child sharing. |
| missing_tool_call_id | No id to cite in a note |
| non_local_backend | A backend signal says something other than local: process TERMINAL_ENV set to anything but local, a bound Hermes terminal scope whose TERMINAL_ENV is not local, or a backend kwarg. Checked before dispatch and again before seeding, because Hermes may bridge config.yaml into TERMINAL_ENV during the first dispatch. |
| terminal_policy_refused | Hermes bound its refusal scope (the profile's terminal policy could not be resolved) |
| credential_path | The path has one of the credential names or directory components listed above |
| refresh | The first read after /read-once refresh <path> |
Results are never seeded if they are not a str, look like an error, are a Hermes unchanged-stub, or are JSON with truncated, truncated_lines, or a next_offset field. Otherwise a result is stored only if it is exactly the requested window of the bytes the plugin read itself. The window is lines offset through min(offset + limit - 1, line count), split on \n. Three forms are accepted: the raw bytes of that window, the N|line form Hermes builds, or a Hermes JSON page whose content is that N|line form and whose total_lines and file_size, when present, match the file. A page that starts at another offset, skips or reorders lines, stops early, or differs in any byte is not stored (content_mismatch), so it can never hit. A plain source file that merely mentions next_offset can still be stored.
Fail open to Hermes, fail closed to optimization. If the plugin raises before
dispatch, it calls next_call once and returns that result. If it fails after
next_call succeeded, it returns that result and never calls again. If
next_call itself raises, the exception propagates unchanged and nothing is
retried.
Command
/read-once status
/read-once stats
/read-once refresh <path>
statusandstatsare the same report. The report shows the mode, the
notice setting, the root, the epoch, the hit count, the approximate tokens
not resent, the entry and visibility counts, and the counters for bypass
reasons, misses by reason, and not-seeded reasons. It never shows file
contents. The token line isapprox tokens not resent this session: N (approx).
Nis the sum ofround(result length / 4)over hits. It is an approximation.
It is not a percentage and it is not a savings claim.refresh <path>removes that path from every scope and forces the next
matching read through normal dispatch. The reply is
read-once: next read of <path> will be fresh (invalidated N entries).
It matches entries byrealpathand by(st_dev, st_ino), so a hardlink or
a case variant on a case-insensitive volume invalidates the same file. It
callsstaton the path but never opens or reads it.
Known limits and races
- Concurrent writer. The plugin hashes the file immediately before a hit and
before and after a miss's dispatch. A writer that changes the file after
the hit check, or that changes and restores it between those hashes, is not
detected. The note is true at the check, not afterwards. - Visibility matching is exact. If Hermes transforms a tool result after the
middleware runs (for example,transform_tool_result), the delivered text
won't match and the entry never becomes eligible. This fails closed. - Two different files with byte-identical results can confirm each other's
visibility. The note still cites the original call id. - Parallel misses for the same key may both dispatch. There is no request
coalescing. - Whole-file reads that share one
(path, offset, limit)key can hit. A line
range uses another key and passes through. - Stats report an approximate token count from result length divided by 4.
The wordapproxis always on that line. The plugin makes no percentage claim
and no dollar claim. - Hermes has no dim status printer. Read Once does not post a chat notice.
- Backend residuals. Hermes passes no backend to middleware. The plugin
reads the two signals Hermes 0.23'sread_fileresolves from: process
TERMINAL_ENV, and the per-turnhermes_terminal_scopeContextVar. It
finds the ContextVar by name incontextvars.copy_context()and does not
import Hermes. If a later Hermes renames that variable, the plugin falls
back to processTERMINAL_ENVonly.test_real_loader.pycatches that
rename. Hermes also caches each task's file-ops environment in a private
dict. IfTERMINAL_ENVchanges mid-process, Hermes can keep using a cached
environment of the old backend, and the plugin cannot see it without a
private import.
How to disable
Leave READ_ONCE_HITS unset, or set it to anything other than 1. The plugin
then only observes. To stop it entirely, remove or disable the plugin in Hermes.
Tested runtime
Hermes 0.23.0 only, commit 2bd378ed1b8d6bf2192cb26e0a55ff3091e6bd19.
requires_hermes is >=0.23.0. No earlier release was tested.
License: MIT. See LICENSE.
Tests
python -m pytest plugin/read-once/tests
test_read_once.py uses tmp_path and does not import Hermes.
test_real_loader.py imports Hermes 0.23 middleware. Run that file with the
Hermes 0.23 virtualenv, or the suite skips those cases on an interpreter that
cannot import it.
README from the reviewed commit e75af16; it updates when the author re-pins.

batmnnn