Young's Mind
06 Implementation

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.

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:

LineWhenWhat it records
turn_context.py:912no external providerprovider="none", result_count=0 — a deliberate zero
turn_context.py:931after 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 was holographic on a throwaway recallprobe profile, 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.

Copyright © 2026