Skip to content

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.

pip install 'popoto[mcp]'
popoto-memory doctor

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:

export POPOTO_MEMORY_URL=redis://localhost:6379/1

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:

  • retrieval should say lexical (query-sensitive). If it says composite -- QUERY-BLIND, the prompt text is being ignored when ranking. See Query-Blind Retrieval.
  • last assemble / last capture are how a silent break becomes visible. A hook that stopped firing shows a stale timestamp, or never.
  • failures counts 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) and heuristic_notice are reports rather than integration errors, so they are excluded from this line and from the failures: payload over MCP; a non-zero evicted count instead prints its own data-loss line naming the count and POPOTO_DEFAULT_MEMORY_MAX_RECORDS.
  • url source says which variable produced the URL — POPOTO_MEMORY_URL, REDIS_URL, or default. 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 ***; doctor output 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.