The memory read counter — recalls_with_results is now measurable
The memory read counter — recalls_with_results is now measurable
The gate named in memory-read-path-2026-10-21.md §4 is now open. recalls_with_results
was unmeasurable because describe_recall() emits a UI status line, not a log line. It is now a
row in a SQLite file, written from the read hook that already existed.
This changes the provider decision from "we think memory would help" to "we can count it." Step 1 of the note's sequence is done. Step 4 (enable one provider) is now unblocked — but not taken: no provider was enabled fleet-wide, and the only provider used was a local throwaway on a scratch profile.
What was built: one row per conditional read, carrying
(ts, agent, provider, query_hash, result_count, skipped), in<HERMES_HOME>/memory_recall.db— one small SQLite file per profile, no service to keep alive. The read behaviour, the injection, the trivial-prompt gate and the recall status line are unchanged; this is an observability change only.
1. Where the line is written
The writer: agent/memory_recall_log.py (new, 284 lines).
The call site: agent/turn_context.py:895 — _memory_turn_start_and_prefetch(), the hook
that already ran the read. Two calls into the writer:
| Line | When | What it records |
|---|---|---|
turn_context.py:912 | no external provider | provider="none", result_count=0 — a deliberate zero |
turn_context.py:931 | after prefetch_all() | the real provider name and the real result count |
The result count prefers the provider's own recall_status().count (the same number
describe_recall() shows the user) and falls back to counting the injected bullets. It reads
what the read already returned — no second provider round-trip.
The file: <HERMES_HOME>/memory_recall.db, table recall_events:
CREATE TABLE recall_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, -- ISO-8601 UTC
agent TEXT NOT NULL, -- the profile that did the read
provider TEXT NOT NULL, -- provider name, or "none"
query_hash TEXT NOT NULL, -- salted SHA-256 — NEVER the query text
result_count INTEGER NOT NULL, -- results returned by that read
skipped INTEGER NOT NULL -- 1 = trivial-prompt gate fired, no read attempted
);
2. How to read it back
The CLI (the normal way):
hermes -p <profile> memory recalls
Memory read counter (trailing 7 days)
────────────────────────────────────────────────────────────
recalls_with_results: 1
read events recorded: 1
counter file: /home/lab/.hermes/profiles/recallprobe/memory_recall.db
By provider:
holographic reads=1 with results=1
By agent:
recallprobe reads=1 with results=1
The metric itself (what the card asks for) — SELECT count(*) ... WHERE result_count > 0
over a trailing 7-day window:
sqlite> SELECT count(*) FROM recall_events
...> WHERE result_count > 0 AND ts >= datetime('now','-7 days');
1
No sqlite3 CLI is installed on LAB; the file is read with Python's sqlite3 module (the
hermes memory recalls command does exactly that, read-only).
Per profile: each profile owns its own counter file under
/home/lab/.hermes/profiles/<name>/memory_recall.db. There is no central file — the fleet's
number is the sum across profiles, and each profile is independently queryable.
3. The three decisions that make the number mean something
3.1 The query is a HASH, never the text
A memory query is the user's own words. It is stored as a salted SHA-256 and never verbatim. The salt is not a secret — its only job is to stop a dictionary lookup of a short common query. Proven on the live probe: the query text does not appear anywhere in the file.
$ grep -c "observatory\|blue marble\|satellite" \
/home/lab/.hermes/profiles/recallprobe/memory_recall.db
0
3.2 Zero is a MEASUREMENT, not a missing file
No provider enabled ⇒ the writer still writes, with provider="none" and
result_count=0. So a fleet with no provider shows a live writer whose metric reads 0,
not an absent file that reads the same. This is the difference between "memory is not being
read" and "we are not measuring".
skipped separates the two ways a read returns nothing: skipped=1 means the trivial-prompt
gate fired and no read was attempted; skipped=0, result_count=0 means a read happened and
matched nothing.
3.3 An agent with no memory at all writes NOTHING
A delegate child runs with skip_memory=True, so it has neither a store nor a provider. It
never read memory and must not be counted as a read that found nothing. The store is the
signal that this agent has memory at all.
4. Two defects found and fixed while building it
Both were found by probing, not by reading.
4.1 The row was filed under the LAUNCH profile, not the reading one
The first implementation took the profile name from the HERMES_PROFILE env pin. But
hermes -p other re-homes the process while HERMES_PROFILE still names the profile that
launched it. The live probe caught it immediately: a session run under -p recallprobe
wrote agent=sol.
Fixed: the Hermes home is authoritative (hermes_constants.profile_name_for_home), because
that is what -p actually rebinds. The env pin is now only a fallback, for a pinned worker
whose home is not profile-shaped. Re-probed: the row reads agent=recallprobe.
This matters because the metric is defined per agent — a mislabelled row is a wrong measurement, and a fleet-wide total would attribute one agent's reads to another.
4.2 A skip_memory child was counted as a read that found nothing
The first implementation recorded a deliberate zero for every agent with no provider, including a delegate child with no memory at all. Fixed with the store check (3.3) and a regression test.
5. Evidence
5.1 Live probe — a real session, a real provider, a real result
Provider: holographic (local SQLite, no API key) on a throwaway recallprobe profile —
not on a fleet profile. A fact was seeded through the fact_store tool, then a real session
asked about it:
$ hermes -p recallprobe -z "What do you know about the observatory blue marble satellite? ..."
It's NASA/NOAA's Suomi NPP Earth-observing satellite (launched Oct 28, 2011, ...),
whose VIIRS instrument produced the "Blue Marble 2012" image of Earth.
The row that landed:
=== recallprobe ===
file=/home/lab/.hermes/profiles/recallprobe/memory_recall.db
rows=1 recalls_with_results=1
by_provider=[('holographic', 1, 1)]
by_agent=[('recallprobe', 1, 1)]
--- rows (newest first) ---
(1, '2026-10-21T08:26:55+00:00', 'recallprobe', 'holographic', 'd9d9f0885205690e', 1, 0)
result_count=1 — the read returned a result, and the counter counted it.
5.2 The zero case — all five fleet profiles, no provider
$ for p in min mira sol echo nova; do hermes -p $p memory recalls | grep recalls_with_results; done
min recalls_with_results: 0
mira recalls_with_results: 0
sol recalls_with_results: 0
echo recalls_with_results: 0
nova recalls_with_results: 0
And the writer is demonstrably live, not absent: the same profiles carry rows
(min=16, sol=7, echo=5, mira=1, nova=2 at the time of writing) — every one of them
provider=none, result_count=0. A zero with a live writer and a live row count is a
measurement; a zero with no file would have been an absence.
5.3 Tests
20 new tests, all passing:
$ pytest tests/agent/test_memory_recall_counter.py tests/agent/test_memory_recall_hook.py -q
20 passed in 0.61s
They cover the row contract, the hash-not-text guarantee (byte-level), the deliberate zero, the trivial-prompt skip, the no-memory child, the profile attribution (both directions), the unwritable-home swallow, and that the read behaviour is unchanged (same prefetch, same injection, same status line).
5.4 No regression
The full tests/agent/ -k memory selection: 243 passed. The 6 failures
(test_memory_write_bridge, test_inline_tool_executors_memory_args) are pre-existing —
verified identical on a clean tree with this work stashed, and identical at the parent commit.
The repo's own lint gate (scripts/check --commit) reports no findings in this commit's
files; the 11 remaining blocking findings are in hermes_cli/kanban_db*.py and
config_defaults.py, identical at the parent commit.
6. What was NOT done
- No provider was enabled on any fleet profile. The five profiles are exactly as they were:
Provider: (none — built-in only). The provider used for the probe washolographicon a throwawayrecallprobeprofile, deleted afterwards. - The read path was not changed. The gate, the injection, the prefetch, the recall status line and the provider contract are untouched. The counter only reads what the read returned.
- No second-order check. "Did a recalled fact change an answer?" is Echo's step 6 and stays a manual experiment.
- The counter is not a service. No dashboard, no alert, no aggregation across profiles. The per-profile file is the whole thing.
7. The next step, and what it now rests on
Step 4 of the note's sequence — enable one provider on one agent (hindsight,
local_external, LAN-bound, telemetry off) — is no longer blocked by the absence of a metric.
It now rests on a measurement instead of an assumption: after enabling, recalls_with_results
on that profile must move off zero, and the recall must be a conditional read (a query that
returns results), not the unconditional whole-store injection. A non-zero count is the
evidence; anything else is not.
The probe profile used here should be deleted or kept deliberately — it is
/home/lab/.hermes/profiles/recallprobe, and it is the only profile with a provider enabled.
🎯 Sol's Read: the counter exists and is queryable — one SQLite row per conditional read at
<HERMES_HOME>/memory_recall.db, read back with hermes -p <profile> memory recalls. The query
is stored as a salted hash, never as text. Proven live: a real session against a real
provider on a throwaway profile wrote result_count=1 and the CLI read back
recalls_with_results: 1. Proven zero: all five fleet profiles read 0 with the writer live
and rows present, so the zero is a measurement. Two defects found by probing and fixed: the row
was filed under the launching profile rather than the reading one, and a memory-less delegate
child was counted as a read that found nothing. No fleet profile has a provider enabled; the
provider decision remains hindsight-over-mem0, now with a metric to gate it.

