Harness Integration¶
Subconscious memory for Claude Code, Codex, Hermes, and OpenClaw, backed by your own Redis or Valkey. 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 hook directory |
| OpenClaw | no, plugin required | no, plugin required | yes (MCP) | MCP config only |
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 | vendor documentation only |
| OpenClaw | vendor documentation only |
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, cwd),
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.
Known gap: the read→write handoff is a session-wide FIFO, not a turn ID.
MemoryService queues selected record keys per session_id as a plain
list and pops one entry per write event; it does not key the entry on a
turn identifier, even though Claude Code and Codex fixtures both carry one
(prompt_id / turn_id). A read whose paired write event never fires — an
aborted turn, a crashed session — leaves its entry in the list and shifts
every later push/pop pairing by one, and a SubagentStop-configured
session pops one queued entry per subagent against a single earlier read.
The failure mode is misattributed outcome reporting (confidence/decay
adjustments land on the wrong turn's records), not a crash, and it is
bounded by MAX_PENDING_TURNS (32) per session. Tracked as follow-up work
in #574; not fixed in this PR.
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¶
SubconsciousMemory.inject_context(), the library API, appends to
messages[0], which invalidates a cached system prefix on every turn. The
hook path does not do that: 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.
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 Redis.
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. The zero-configuration path is "local Redis or
Valkey on the default port, memories tagged with this project's agent id" —
see the scoping caveat on POPOTO_MEMORY_AGENT_ID below before wiring the
hook into more than one project on one database.
| Variable | Default | Notes |
|---|---|---|
POPOTO_MEMORY_URL |
REDIS_URL, else redis://localhost:6379/0 |
Valkey URLs are identical |
POPOTO_MEMORY_AGENT_ID |
basename of the working directory | Tags writes and is honored as a read filter on the composite-score retrieval path. The shipped default is the lexical/BM25 path, which does not yet filter by it (#576) — a project's memories can be retrieved by another agent_id on the same database. Not a project-isolation boundary today; use a distinct POPOTO_MEMORY_URL database per project for that instead |
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_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 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/0
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.- Redis unreachable exits 1 and prints the command to fix it.
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. It also tries to
increment a Redis counter that doctor reads back, but that write is
best-effort against the same client that just failed: when the failure is
Redis being unreachable, the counter does not get incremented, which is
exactly when doctor has nothing to read back anyway.
| Situation | Behavior |
|---|---|
| Redis down, read hook | exit 0, no stdout, one log line, four lines on stderr, counter not incremented (same client is down) |
| Redis down, write hook | exit 0, turn dropped, logged. No retry queue |
| 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 |
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.
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.
MemoryLifecycle can hard-delete them, 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 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/ declarative harness assets, one directory per harness
.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.