Harness Integration¶
Subconscious memory for Claude Code, Codex, Hermes, and OpenClaw, backed by your own Redis, Valkey or PostgreSQL 18+ (On Postgres). No API keys, no hosted service, no schema to write.
Then paste your harness's config block. The next turn silently carries relevant memories; the turn after that remembers what was just said.
Pre-release
popoto[mcp] is not on PyPI yet. Until a release ships, install from a
checkout: pip install -e '.[mcp]'. The hook path needs only
pip install popoto; the extra is for the MCP server.
Hooks recall and capture; MCP tools search, save, and correct¶
That split is the whole design, and it is the part worth understanding before anything else.
Subconscious memory means the memory layer runs on every turn, whether or not the model asks for it. A plain MCP tool server cannot deliver that, because MCP tools are agent-elected: the model calls a tool when it decides to. Memory then becomes something the model chooses, which is instructed memory wearing a subconscious label.
Vendors who bet the other way say so. Supermemory, explaining why it shipped a plugin on top of its MCP server: "We cannot control when claude code chooses to run the tools. This means that we have no control / data point to learn things from, and a memory system is only good if there's things to recall later." Mem0's troubleshooting page tells users that if memories are not being captured, they installed the MCP-only variant and need the marketplace plugin, "since MCP-only installs require manual memory operations."
Every harness here has a deterministic, model-independent surface that fires per turn. That surface, not MCP, carries recall and capture. MCP remains the portable core for the discretionary half: searching on demand, saving explicitly, correcting a memory that turned out wrong.
Per-harness capability¶
Auto-inject means memories reach the model with no tool call. Auto-capture means the turn is stored with no tool call.
| Harness | Auto-inject | Auto-capture | Discretionary tools | Setup |
|---|---|---|---|---|
| Claude Code | yes (UserPromptSubmit) |
yes (Stop, async) |
yes (MCP) | 2 commands, or 8 config lines |
| Codex | yes (UserPromptSubmit) |
yes (Stop) |
yes (MCP) | config + feature flag + /hooks trust review |
| Hermes | yes (pre_llm_call) |
yes (post_llm_call) |
yes (MCP) | 2-file plugin directory |
| OpenClaw | yes (before_prompt_build) |
yes (llm_output) |
yes (MCP) | MCP config + plugin install + hook opt-in |
Verification is not uniform either, and the difference matters more than the capability table:
| Harness | Contract verified against |
|---|---|
| Claude Code | a live claude 2.1.220 run; payloads captured and committed as test fixtures |
| Codex | the codex-cli 0.144.4 binary's own hook-input schema, not a live turn |
| Hermes | the real hermes-agent 0.19.0 plugin loader and invoke_hook dispatcher; no live model turn |
| OpenClaw | a live OpenClaw 2026.9.2 run through the shipped plugin; payloads captured and committed as test fixtures |
Guides: Claude Code (the reference), Codex, Hermes, OpenClaw.
What happens on a turn¶
user types
|
v pre-model hook popoto-memory hook
assemble from Redis
|
v context appended to the USER turn
model
|
v post-model hook, async popoto-memory hook
turn saved verbatim, outcomes reported
|
v
next turn's assembly is better
Read path. The harness fires its pre-model event with the user's prompt.
The adapter normalizes the payload to (event, query_text, session_id, turn_id,
cwd) — cwd only when the harness sends one; Hermes's plugin hooks carry no
cwd key at all, so that field is always None for Hermes and the plugin
passes a prebuilt service instead. MemoryService.assemble() runs ContextAssembler over
DefaultMemory on the lexical/BM25 path, and the
formatted block goes back in the harness's own response shape:
{"hookSpecificOutput": {"additionalContext": "..."}} for Claude Code and
Codex, {"context": "..."} for Hermes, {"appendContext": "..."} for
OpenClaw. The selected record keys are queued under the session so the
following write event can report outcomes against exactly those records.
Write path. The harness fires its post-model event carrying the
assistant's final text. The turn is saved through
RawTurnExtractionProvider, one verbatim record. The queued keys from the
read path are popped and reported through ObservationProtocol with
outcome used, which confirms the staged read and auto-resolves
predictions — _apply_used() (src/popoto/fields/observation.py:391)
explicitly does not touch ConfidenceField, CyclicDecayField, or
DecayingSortedField. Outcome acted, which does raise confidence and
affect decay, is reserved for an explicit, discretionary memory_feedback
MCP tool call — the automatic hook path never emits it. Nothing is written
to stdout, and on Claude Code this hook is configured "async": true so it
never sits on the turn's critical path.
The read→write handoff is keyed on the harness's turn identifier.
MemoryService queues the selected record keys per session_id and tags
each entry with the turn id the payload carried — Claude Code's prompt_id,
Codex's turn_id. The write event claims its own entry by that tag rather
than popping whatever sits at the head, so an aborted turn, a crashed
session, or a SubagentStop-configured session reporting more often than it
read no longer shifts every later pairing by one. A second report for a turn
already resolved resolves nothing, and a turn id that matches no staged entry
resolves nothing rather than falling back to the head of the queue.
OpenClaw sends one, though not on the event: its hooks take a second
ctx argument whose runId is identical across a turn's
before_prompt_build and llm_output, and the plugin forwards it as
turn_id. That matters more there than elsewhere, because llm_output is an
Observe hook whose handlers run concurrently and may overlap the next turn --
keyed pairing makes the overlap correct by construction. Hermes sends one too:
both plugin invoke sites carry turn_id as a flat kwarg
(agent/turn_context.py:696-707, agent/turn_finalizer.py:484-493), so its
sessions are keyed the same way (#704 closed #688 with this). A session
using a harness that genuinely sends no turn id, or a queue written entirely
before this behavior shipped, still falls back to positional FIFO pairing so
no pending turn is lost. Both paths are bounded by MAX_PENDING_TURNS (32)
per session and a one-hour TTL.
Set POPOTO_MEMORY_TURN_KEYED=0 to restore the pre-1.9 session-wide FIFO for
every harness. Shipped in
#574.
Neither path parses a transcript file. Every harness supplies the assistant's final text on the event itself, so a change to the JSONL format cannot break capture.
Injection lands in the user turn, never the system prompt¶
Claude Code additionalContext, Hermes pre_llm_call context, and OpenClaw
appendContext all land in the user turn. Hermes documents the choice as
caching-motivated. MemoryService returns a context string and never
touches a message array, so where the text goes is the harness's decision.
SubconsciousMemory.inject_context(), the library API, follows the same rule:
it appends after every existing message. Passing position="system" restores
the pre-1.9 placement in messages[0], which invalidates a cached system
prefix on every turn — available for callers who need the block read as
system-level instruction, and priced accordingly. See
Prompt Cache Efficiency.
The write path is raw ingestion, on purpose¶
Issue #489 evaluated LLM extraction, heuristic sentence-splitting, and raw turn ingestion on the same slice. Every extraction arm lost to raw ingestion on judged accuracy: heuristic 0.2078 against raw 0.3636.
A hook fires on every turn, so the harness surface would otherwise generate
more memories through the measured-worst write path than every other Popoto
usage combined, and the quality of that corpus is exactly what a new user
judges the product on. So
RawTurnExtractionProvider is the default here:
one turn in, one record out, stored verbatim.
POPOTO_MEMORY_INGEST=heuristic switches to sentence-splitting and logs
that measured cost the first time it is used.
Credentials and off-the-record turns are dropped before ingestion¶
"Stored verbatim" is the point of raw ingestion and also its hazard: a hook fires on every turn, and terminal turns contain pasted API keys. The never-record firewall runs ahead of the write path so that class of content never reaches storage, on either backend.
It is deterministic — regex and entropy, no model — and it runs at two
points. At the turn level, inside SubconsciousMemory.extract_memories(),
before the extraction provider is called: an off-the-record marker voids
the entire turn rather than a guessed span, and on the
ClaudeExtractionProvider path the text is never sent to the API at all. At
the record level, inside Model.save(), before serialization or any index
write. DefaultMemory — the model this harness writes through — carries the
mixin, so this is on by default with no configuration.
A drop leaves a content-free tombstone: a random id and a reason code, never
a fragment of the text. Read the tally with
DefaultMemory.never_record_counts().
Two caveats worth knowing before you rely on it. Over-blocking is accepted by
design, so a long random-looking token in an otherwise useful turn can cost
you that memory. And the guarantee covers an enumerated class of shapes, not
"secrets" in general — the feature
page lists the holes
explicitly. POPOTO_NEVER_RECORD_DISABLE=1 turns it off entirely; note that
this is a core Popoto variable, not one of the POPOTO_MEMORY_* harness
variables below.
Configuration¶
Every variable is optional, with one exception you will hit on a fresh install: the database must not be 0. Name a database and the path is "local Redis or Valkey on the default port, memories tagged with this project's agent id".
| Variable | Default | Notes |
|---|---|---|
POPOTO_MEMORY_URL |
REDIS_URL, else redis://localhost:6379/0 |
Valkey URLs are identical. Database 0 is refused — see below |
POPOTO_MEMORY_ALLOW_DB0 |
unset | Set to 1 to write agent memory to database 0 anyway. Deploy-level opt-in, deliberately an environment variable: a hook is a bare command string with nowhere to pass Python arguments |
POPOTO_MEMORY_AGENT_ID |
basename of the working directory | Tags writes and is honored as a read filter on every shipped retrieval path, lexical/BM25 included (#576, fixed in 1.9.0). Before 1.9.0 the BM25 path ignored it and one project's memories could surface in another's turns on the same database |
POPOTO_MEMORY_MAX_ITEMS |
5 |
Diverges from the benchmark; see below |
POPOTO_MEMORY_MAX_TOKENS |
800 |
Under Codex's 2500-token additionalContextLimit |
POPOTO_MEMORY_INGEST |
raw |
raw or heuristic |
POPOTO_MEMORY_TURN_KEYED |
1 |
0 restores the pre-1.9 session-wide FIFO handoff, pairing reads to writes by queue position instead of by turn id |
POPOTO_MEMORY_ENABLED |
1 |
Kill switch that needs no config edit |
POPOTO_MEMORY_LOG |
~/.popoto/memory.log |
Where swallowed errors land |
A malformed value falls back to its default rather than raising: a typo in a
harness config must not break a turn. The database number is the exception —
both a database-0 target and a URL with no database at all (redis://host:6379/,
which is not database 0) raise, because the alternative is writing your
memories somewhere you did not ask for.
Database 0 is refused¶
MemoryService raises Db0RefusedError when the database it would write to
is 0. The error names the variable to change and suggests an empty database
it found on your server.
Database 0 is where a bare redis-cli, a scratch script, and most other
local tooling land by default, so it is the database most likely to already
hold something you care about — and the one most likely to be flushed by
someone debugging an unrelated problem. Popoto's own test plugin has always
refused it for the same reason; the library refusing it while steering users
into it was the contradiction (#584).
Pick any other database:
Db0RefusedError subclasses ValueError, and every entry point renders it
rather than tracebacking: doctor prints it and exits 1, the MCP tools
return it as an error result, and the hook logs it and exits 0 so the turn
survives. If database 0 really is where this corpus belongs,
POPOTO_MEMORY_ALLOW_DB0=1 opts in at deploy time.
POPOTO_MEMORY_ENABLED=0 short-circuits the check entirely. A disabled
memory layer writes to no database, so there is nothing to refuse — the kill
switch stays a clean no-op on a database-0 host, with no log line per turn.
On Postgres¶
The hook, the MCP server, doctor and demo run on the
Postgres backend with no Redis at all
(#814). Select it the way
any Popoto process does; the POPOTO_MEMORY_* variables above keep their
meaning, except the two that only describe a Redis connection:
export POPOTO_BACKEND=postgres
export POPOTO_POSTGRES_URL=postgresql://db.internal:5432/agents
export POPOTO_POSTGRES_SCHEMA=popoto # optional; default "popoto"
| Variable | On Postgres |
|---|---|
POPOTO_BACKEND |
postgres selects it for the hook process, the MCP server and doctor. Without it the integration stays on Redis, exactly as before |
POPOTO_POSTGRES_URL |
The DSN. Required: selecting Postgres without it fails every operation (logged, counted, and reported by doctor) rather than falling back to Redis |
POPOTO_POSTGRES_SCHEMA |
Where the tables live. Created on first write |
POPOTO_MEMORY_URL, REDIS_URL |
Not read. No Redis connection is bound, probed or dialled, so a REDIS_URL your harness happens to export (even one naming database 0, or a server that is down) changes nothing |
POPOTO_MEMORY_ALLOW_DB0 |
Not applicable. Database 0 is refused is a Redis rule |
Everything the integration keeps between turns moves with the backend. The
memories are DefaultMemory rows; the read-to-write handoff, the per-session
injection suppression and the last-success timestamps are typed tables
(popoto_harness_pending, popoto_harness_injected, popoto_harness_event)
that expire with the session, as the Redis keys do; and the counters doctor
reads are rows of popoto_counter, alongside DefaultMemory's eviction
counter. The behavior is the same on both backends, including the turn-keyed
claim (two writers racing on one turn: exactly one reports it) and the
32-entry cap. One difference is stricter: a redelivered read hook is
deduplicated under a lock on Postgres, where Redis only checks before
writing. Session state is not migrated by migrate_redis_to_postgres (an
hour of handoff is not worth carrying over), and counters start from zero on
the new backend.
doctor reports the Postgres server instead of Redis:
$ popoto-memory doctor
popoto-memory doctor
status enabled
backend postgres
postgres dsn host=db.internal port=5432 dbname=agents
url source POPOTO_POSTGRES_URL
postgres reachable, postgresql 18.6, ping 1.37 ms
schema popoto (10 tables)
pgvector 0.8.7 (public)
health ok, dropped_writes=0
agent id my-project
model DefaultMemory
retrieval lexical (query-sensitive)
...
The DSN line shows host, port, database and user only, never the password.
postgres is UNREACHABLE (with the error and the backend's health record)
when the server does not answer, and UNSUPPORTED when it is older than
PostgreSQL 18 or not UTF8; both exit 1, as an unreachable Redis does.
schema says not created yet before the first write. pgvector is
informational: DefaultMemory does not need it, only models with an
EmbeddingField do.
doctor --json has the same keys on both backends, so a script written
against one shape never meets a KeyError on the other:
| Key | On Redis | On Postgres |
|---|---|---|
backend |
"redis" |
"postgres" |
reachable |
server answered | server answered and is PostgreSQL 18+ / UTF8 |
server |
"redis 8.2.1" or "valkey 8.1.0" |
"postgresql 18.6" |
postgres |
null |
dsn, schema, schema_exists, schema_tables, server_version, supported, pgvector, database, health |
redis_url |
the redacted URL, as before | null |
redis_reachable |
as before | null (not applicable, never false) |
On Redis every key that existed before keeps its value and position;
backend, reachable and postgres are appended.
A hook on an unreachable Postgres behaves as one on an unreachable Redis
(see Failure behavior): exit 0, no output, one log line,
and a stderr warning that names the backend, such as
popoto memory injected_read failed (backend: postgres): .... The hook
caps both of its Postgres waits at the same 1 second it gives Redis: the
connect timeout, so an outage costs the prompt about a second once per hook
process, and the statement timeout, so a held lock (a migration, maintain,
a stuck writer) costs about a second per statement instead of the library's
30. Only the hook subcommand lowers them; the MCP server and an in-process
caller such as the Hermes plugin keep the library defaults. The hook process also does not print
psycopg.pool's connect-retry warnings or the backend's own outage line,
because the hook's warning carries the same error; its stderr is that one
line.
doctor creates nothing. Pointed at a schema that does not exist yet, it
reports the schema as not created yet with a record count of 0 and skips its
latency probe, because on Postgres the first read creates the schema and its
tables.
To remove a scratch agent's bookkeeping (handoffs, suppression sets,
counters, last-success stamps) on either backend, call
MemoryService.purge_integration_state(). It never deletes memory records;
delete those through the model.
The one deliberate divergence from the benchmarked configuration¶
Scoring is unchanged: score_weights={"relevance": 1.0} and the
lexical/BM25 retrieval path that DefaultMemory selects, exactly as
benchmarked.
max_items and max_tokens are not. The retrieval benchmark ran at
max_items=20, a reasonable budget for a question-answering evaluation and
the wrong one for a coding harness, where a turn fires every few seconds and
context is contested by file contents, tool output, and the system prompt.
Codex additionally caps injected context at 2500 tokens. The defaults here
are 5 items and 800 tokens. Raise them with the environment variables if
your turns are long and infrequent.
Diagnosing¶
A hook has no console, so popoto-memory doctor is the error surface.
$ popoto-memory doctor
popoto-memory doctor
status enabled
redis url redis://localhost:6379/1
url source POPOTO_MEMORY_URL
redis reachable, valkey 9.1.0, ping 4.91 ms
agent id my-project
model DefaultMemory
retrieval lexical (query-sensitive)
ingest raw
budget 5 items / 800 tokens
records 142
hook read 1.71 ms (in-process)
successes assemble=88, capture=142
failures none
last assemble 2026-08-07T18:41:03+00:00
last capture 2026-08-07T18:41:07+00:00
log /Users/dev/.popoto/memory.log
What to read:
retrievalshould saylexical (query-sensitive). If it sayscomposite -- QUERY-BLIND, the prompt text is being ignored when ranking. See Query-Blind Retrieval.last assemble/last captureare how a silent break becomes visible. A hook that stopped firing shows a stale timestamp, ornever.failurescounts swallowed exceptions per operation. Every one of them also wrote a line to the log, and the last five lines are printed underneath.evicted(see Corpus growth) andheuristic_noticeare reports rather than integration errors, so they are excluded from this line and from thefailures:payload over MCP; a non-zeroevictedcount instead prints its own data-loss line naming the count andPOPOTO_DEFAULT_MEMORY_MAX_RECORDS.url sourcesays which variable produced the URL —POPOTO_MEMORY_URL,REDIS_URL, ordefault. It is the fastest way to find out that the memories you are looking for went to a database you did not intend. Any password in the URL is redacted to***;doctoroutput ends up pasted into transcripts.- Redis unreachable exits 1 and prints the command to fix it.
- Database 0 exits 1 before connecting, with the refusal message.
- On Postgres the connection block is the Postgres one; see On Postgres.
popoto-memory doctor --json emits the same data machine-readably.
Failure behavior¶
A memory failure must never break a user's turn, so every path swallows and
continues. Swallowing silently is the failure mode that makes users conclude
memory does not work, so each swallowed exception always appends a line to
POPOTO_MEMORY_LOG -- that's the reliable channel -- and logs a warning to
stderr that names the backend in use (... failed (backend: redis): ...).
It also tries to increment a counter that doctor reads back, but that
write is best-effort against the same store that just failed: when the
failure is the server being unreachable, the counter does not get
incremented, which is exactly when doctor has nothing to read back anyway.
The same holds on Postgres, where an outage (BackendUnavailableError)
short-circuits the rest of the hook process as a Redis connection error
does.
An outage costs one attempt, not five. The integration binds its
connection with a 1-second connect and socket timeout and no redis-py
retries (the default is 10), and once an outage has been recorded -- a redis
ConnectionError/TimeoutError, or, for a model bound to Postgres, a
popoto.backends.BackendUnavailableError (#816; any member of
popoto.backends.OUTAGE_ERRORS) -- later assemble() calls in that hook
process, and the failure-counter write, are skipped outright. The shipped Claude Code and Codex hook configs also carry an
explicit "timeout": 10. Against a server that accepts connections and
never answers, this is the difference between roughly 25 seconds on the
user's prompt and under 2.
| Situation | Behavior |
|---|---|
| Redis down, read hook | exit 0, no stdout, one log line, counter not incremented (same client is down). One connection attempt, ~1 s, not one per operation |
| Redis down, write hook | exit 0, turn dropped, logged. No retry queue |
| Database 0 with no opt-in | Db0RefusedError from MemoryService construction: doctor prints it and exits 1, MCP tools return an error result, the read hook logs it and exits 0 — once per turn until the URL changes. POPOTO_MEMORY_ENABLED=0 skips the check and logs nothing |
| Healthy Redis, empty corpus (fresh DB / first run) | exit 0, no stdout, one stderr line -- a BM25 advisory that it collected no query signal and fell back to composite (query-blind). Expected on a user's first turn after install; stderr goes quiet and stdout carries content once the corpus is seeded. See Query-Blind Retrieval. |
| Malformed JSON on stdin | exit 0, no output, logged as hook_decode |
| Empty prompt | no retrieval attempted, no output |
| Empty assistant message | nothing written |
Missing session_id |
recall still runs; outcome reporting degrades to a no-op |
| Nothing retrieved | no additionalContext key at all, rather than an empty header |
| Turn dropped by the never-record firewall | nothing written, no log line, no counter increment -- a deliberate drop is not a failure. Counted in $NR:DefaultMemory:counts instead |
Output is built as one string and written once, because Codex treats stdout
that starts with { but fails to parse as a hook failure.
MCP tools¶
Four tools, one memory_ prefix, frozen. They end up in users' configs and
in other projects' docs, so a rename breaks installs silently; a test
asserts the four names literally.
| Tool | Purpose |
|---|---|
memory_search |
Find something specific mid-task that was not already injected |
memory_save |
Store a deliberate fact the turn text would not preserve |
memory_feedback |
Mark a memory contradicted or acted, adjusting its confidence without deleting it |
memory_status |
Connection, scope, retrieval mode, record count, failures (an evicted count — records selected for DefaultMemory eviction — is reported separately from failures, see Corpus growth) |
Errors come back as MCP error results with a readable message, never a traceback rendered as tool output.
An explicit memory_search does not consume the outcome-reporting slot of a
subconscious injection: a deliberate search is not a per-turn recall and must
not be reported as one.
Latency¶
The read hook is synchronous and on the critical path of every turn. The
budget is 400 ms p95 end to end, enforced by
tests/test_integrations_latency.py.
Measured on an Apple M-series laptop, Python 3.12.13, redis 8.6.2 on localhost, 50 records in scope, 25 subprocess invocations:
| Measure | Value |
|---|---|
| p50 | 193 ms |
| p95 | 200 ms |
| max | 211 ms |
Nearly all of that is Python interpreter startup, not Redis: in-process
assembly measures 1-2 ms. popoto.integrations keeps its module scope free
of heavy imports for this reason, and the write hook runs "async": true on
Claude Code so only the read path is ever on the critical path.
Prompt cache efficiency¶
Latency is not the only per-turn cost. Injecting context on every turn puts the memory layer in competition with the provider's prompt cache, and where it writes decides whether that costs the injected tokens or the entire prefix.
The integration is built to append at the tail and never above it.
render_context() in hooks.py emits into the user turn on all four
harnesses — additionalContext for Claude Code and Codex, context for
Hermes, appendContext for OpenClaw — precisely so the cached system prefix
survives across turns. The write path touches only the memory store (Redis
or Postgres), so capture never mutates anything the harness is reading.
The residual cost is that injected blocks accumulate: they cannot be removed
without invalidating everything behind them. Tuning POPOTO_MEMORY_MAX_TOKENS
and suppressing already-injected records are the two levers, and both stay
append-only.
See Prompt Cache Efficiency for the cost model, the four rules, and how to measure it.
Corpus growth¶
Every turn in every session writes a record. agent_id defaults to the
working directory's basename, so corpora are per-project rather than global,
and doctor reports the record count. POPOTO_MEMORY_ENABLED=0 stops
writes without editing any harness config.
Popoto's decay primitives rank stale memories down over time, and since
1.9.0 DefaultMemory also caps the corpus at 1000 records per
agent_id (Defaults.DEFAULT_MEMORY_MAX_RECORDS_PER_AGENT). Past the
cap, each save deletes the stalest record by decay timestamp — a full
delete(), so every index is cleaned and nothing is recoverable
afterwards. Staleness is the relevance decay clock, so a memory that keeps
getting recalled or acted on stays; one nobody has touched goes first.
The cap exists because nothing on the default path evicted before: a
long-lived install grew one record per turn, forever. If your corpus is
already above 1000 records for an agent, the first save after upgrading
deletes the entire excess at once — zcard - cap records, synchronously,
inside that one save() call, not a gradual per-save trim. Measured at
roughly 0.7 ms per record, so an agent 50,000 over the cap blocks that save
for ~35 seconds, inside a hook process whose harness timeout is 10 seconds.
Check with doctor before you upgrade.
The escape hatch that works with no Python seam is the deploy-level
POPOTO_DEFAULT_MEMORY_MAX_RECORDS environment variable — the population
this matters most for is hook adopters using DefaultMemory directly, who
cannot edit model code. It is read fresh on every save, so it can be flipped
without a restart. 0/off/false/no disables eviction; a positive
integer sets the cap (=1 is a cap of one record, not "enabled"); unset
defers to the class attribute; a malformed value warns once (deduped) and is
ignored. It can lower, raise, or disable the default cap, and it can
disable a subclass's cap too — but it can never re-arm eviction on a
subclass that already set _max_records_per_agent falsy. That opt-out is
still the documented subclass-level escape hatch (set _max_records_per_agent
to change the number, or falsy to disable eviction entirely) and always wins
over the env var.
The evicted counter. Each eviction burst INCRBYs
$popoto_memory:counter:{agent_id}:evicted by the number of records
selected for eviction, before any delete runs — so it is a durable,
non-log signal that reaches a hook subprocess whose stderr the harness
suppresses. It counts records the cap selected, not records actually
deleted (the loop skips the saving record's own key and can route a missing
hash to an index repair instead of a deletion), so the invariant is
counter >= records deleted, with equality on the clean path. It surfaces
in MemoryService.status(), the MCP memory_status tool, and
popoto-memory doctor, which prints a data-loss line naming the count and
POPOTO_DEFAULT_MEMORY_MAX_RECORDS when it is non-zero. It is a report,
not a failure — evicted (and heuristic_notice) are excluded from the
failures line in doctor and the failures: payload over MCP.
MemoryLifecycle remains the tiered alternative, but it
requires a tier KeyField that DefaultMemory does not declare, so it is
not wired into the harness path; running it needs a model of your own.
Try it without a harness¶
popoto-memory demo # narrated tour of the loop
python examples/harness_memory/verify.py # the same loop as assertions
Both run against local Redis or Valkey (or Postgres, with
POPOTO_BACKEND=postgres) with no API keys and clean up after themselves. See examples/harness_memory/README.md.
Where the code lives¶
src/popoto/integrations/
service.py MemoryService: assemble, capture, feedback, search, correct, status
config.py environment and cwd resolution
hooks.py harness payload in, harness payload out
mcp_server.py stdio MCP server, four frozen tool names
cli.py popoto-memory: hook | mcp | doctor | demo
demo.py the zero-key loop
plugins/ one directory per harness -- declarative (Claude Code,
Codex) or an executable plugin with a register() entry
point (Hermes, OpenClaw)
.claude-plugin/ marketplace manifest for the Claude Code plugin
popoto.integrations depends on popoto.recipes and popoto.extraction;
nothing in core depends on it. The core install is unchanged, and the hook
path does not require mcp at all.