PseudoLife persistent long-term memory exposed as an MCP server for Claude Code
Project description
PseudoLife-MCP
Persistent long-term memory for Claude Code via the Model Context Protocol.
An MCP server that gives Claude (or any MCP-capable client) a long-term
memory that persists across sessions — surviving context compactions and
/clear resets. Claude is the LLM; this server is its memory on disk.
Quickstart
Requires Docker and Claude Code. One command from clone to first memory:
git clone https://github.com/Pseudogiant-xr/PseudoLife-MCP.git
cd PseudoLife-MCP
ops/install.sh # Linux / macOS
ops\install.ps1 # Windows (pwsh 7+)
The installer runs the preflight (one exact fix line per missing prerequisite), asks which dream extractor should consolidate memories —
- sidecar — the bundled local CPU model; works for everyone, ~9 GB image;
- sonnet-fallback — Claude Sonnet primary via a CLI shim, sidecar as
automatic fallback (needs a logged-in Max-plan
claudeCLI); - sonnet-only — Sonnet only; the sidecar image is never built or pulled (~9 GB lighter; dreams pause while the shim is down) —
then brings the stack up, installs the session hooks, offers to append the
memory-loop block to ~/.claude/CLAUDE.md (required for the loop to actually
fire), runs claude mcp add, and health-checks the daemon. Idempotent:
re-run any time; re-run with --extractor <mode> to switch extractor setups.
Non-interactive: ops/install.sh --extractor sidecar --claude-md append.
Linux (Docker Engine): the docker commands need your user in the docker
group — sudo usermod -aG docker $USER, then log out and back in (the
preflight checks this).
Manual install (the steps the installer automates)
ops/preflight.sh # or ops\preflight.ps1 — checks docker/git/claude, prints the exact fix for anything missing
docker volume create pseudolife-mcp-bank
docker volume create pseudolife-mcp-state
docker compose -f ops/docker-compose.yml up -d --build # first build, once
# Verify, then wire into Claude Code:
curl http://127.0.0.1:8765/health
claude mcp add --transport http --scope user pseudolife-memory http://127.0.0.1:8765/mcp
# Teach Claude the memory loop — REQUIRED, not optional: without a standing
# instruction the tools sit unused. Append the bundled block to your global
# CLAUDE.md (applies to every project):
cat examples/CLAUDE.memory.md >> ~/.claude/CLAUDE.md
# (PowerShell: Add-Content "$env:USERPROFILE\.claude\CLAUDE.md" (Get-Content examples\CLAUDE.memory.md -Raw))
Optional knobs live in ops/.env (cp ops/.env.example ops/.env — the
install/update scripts scaffold it too; every value is commented, a missing
file runs entirely on defaults).
Then in any Claude Code session: "remember that my staging box is
haze-02" → Claude calls memory_store; next session, "which box is
staging?" → memory_search finds it. Browse everything at the Cortex
Console: http://127.0.0.1:8765/ui/. Details: Install
· Wire into Claude Code · Configuration
· Data layout & backups · Cortex Console.
What this is
A memory engine exposed over MCP. There's no chat UI and no bundled model — just tools Claude calls to store and recall what matters. It layers several complementary stores:
- Associative continuum — an 8-tier recency-tiered embedding store
(working → forever) ranked by cosine similarity, with novelty-gated
storage, contradiction detection, and supersession. This is the fuzzy
"what do I know that's related to X" recall. (A test-time-trained neural
blend was trialled and removed in v0.5 — it underperformed plain cosine;
see
docs/2026-06-21-neural-memory-investigation.md. The research code is archived on thearchive/neural-memory-titansbranch.) - Cortex — a slot-keyed store of canonical facts (one current value
per
entity.attribute), with deterministic reads, provenance tiers (user > action > agent), and contender parking instead of silent overwrites. - Knowledge graph — typed entities and edges over those facts, with a
closed relation vocabulary, on-read transitive/inverse inference. Backed by
a Postgres
entitieshub (source of truth) + NetworkX derived read-model behind a swappableGraphStoreinterface; no AGE/Cypher dependency. - World cortex — durable, cited facts about external reality (a current version, a price, who holds a role) with age-decayed trust, kept separate from your own facts.
- Procedural memory (lessons) — what worked, what was a dead end, and what the user corrected, keyed to a task-type and learned from the agent's own work. Dead-ends are first-class and traversable in the graph.
- Reference bank — a ChromaDB document store for RAG over files you ingest.
State lives in Postgres (the durable source of truth) behind a single long-lived daemon; every session attaches through a thin stdio shim. The result: Claude can pick up where it left off, correct itself when facts change, and reason over relationships — without you re-explaining context each session.
Tools exposed
The surface was consolidated 2026-07-02 (55 → 32 tools): lifecycle families
became verb-dispatched tools (memory_dream, memory_forget,
memory_graph_review), and dump/introspection views moved to the Cortex
Console (REST) — the manifest is agent context every session, so it stays lean.
| Tool | Purpose |
|---|---|
memory_store(text, source?, tags?, origin?) |
Remember one durable fact / decision / observation (canonical facts reach the cortex via the dream pass or memory_fact_set) |
memory_search(query, top_k?, filters..., rerank?, bm25?, explain?, verbose?) |
Associative retrieval; canonical cortex facts surface ahead of recall hits; explain=True attaches a ranking trace |
memory_recent(n?, sources?, episodes?, tags?, verbose?) |
Newest stores, timestamp-ordered (debug + session catch-up) |
memory_supersede(old_text, new_text) |
Explicit correction — mark a memory obsolete, keep it as history |
memory_forget(scope, ...) |
Hard-delete from one store: memory (by text/substring/source/episode/tag), fact, world, or lesson (by entity/attribute) |
memory_stats() |
Per-band sizes, hit rates, totals |
memory_get(entry_id) / memory_reinforce(entry_id) |
Dereference a memory id to its full episode (+ consolidated_into); reinforce it after finding it useful |
memory_fact_get(entity, attribute) |
The one CURRENT canonical value at a slot (+ parked contenders); on an empty slot returns ranked candidates (same-entity, then similar slots) |
memory_fact_set(entity, attribute, value, origin?, confidence?) |
Assert a canonical fact deliberately (insert / confirm / supersede / contest) |
memory_fact_resolve(entity, attribute, accept) |
Settle a contested slot — adopt (true) or discard (false) the contender |
memory_history(entity, attribute?) |
With attribute: version timeline at a slot, with writer/temporal stamps. Without: the entity's causal chain — dated fact/entry/edge/lesson events ("what led to X") |
memory_world_set(entity, attribute, value, source_url?, ...) |
Assert a cited WORLD fact (external knowledge; age-decayed trust by freshness class) |
memory_world_search(query, top_k?, verbose?) |
Search world facts — each carries effective_confidence, a stale flag, and its citation |
memory_outcome(task, outcome, about?, detail?, polarity?) |
Record a procedural outcome signal (success/failure/correction); the dream distils signals into lessons |
memory_lesson_search(query, top_k?, verbose?) |
Recall learned lessons for the task at hand — heed polarity - dead-ends; re_verify flags lessons whose subject facts changed since |
memory_dream(action, limit?, cursor?, apply?, snippets?) |
Drive the dream: status / pull / commit / run (server-side extractor) / deep (full-corpus graph consolidation; dry-run unless apply, which snapshots the graph tables first; snippets=false omits candidate evidence; responses carry evidence-enriched merge_proposals for near-duplicate triage) |
memory_graph_review(action, proposal_id?, proposals?, scope?, src?, dst?) |
Work the review queue: list / propose / dismiss_pair / accept_link / reject_link / accept_merge / accept_junk / reject_entity (merge/entity decisions are audit-stamped decided_by=agent over MCP, human via Console) |
memory_session_title(title) |
Name THIS session's auto-opened episode (default titles are generic) |
memory_episode_start(title, hint?) / memory_episode_end() |
Open/close a nested sub-episode for a substantial task; entries stored while open carry its id |
memory_episode_summary(id) |
Stats + tag/source distribution + recent entries within an episode |
memory_consolidation_candidates(query?, episode?, ...) |
Cluster near-duplicate memories ripe for consolidation |
memory_consolidate(replaces, new_text, source?, tags?) |
Atomic supersede + store — replace a cluster with one canonical note |
memory_graph_relate(src, relation, dst, ...) |
Assert a typed edge (closed relation vocabulary; re-assertion bumps confidence) |
memory_graph_unrelate(src, relation, dst) |
Retract an edge (superseded, kept for audit) |
memory_alias(entity, alias) |
Bind an alternative name — lookups resolve aliases first |
memory_graph(entity, depth?, include_facts?, to?, relation_filter?) |
Entity neighborhood (≤3 hops) with derived transitive/inverse edges and per-edge EXTRACTED/INFERRED/AMBIGUOUS provenance tags; to returns the shortest path between two entities |
memory_recall(query, hops?, top_k?, verbose?) |
Multi-hop retrieval for relational questions; low_confidence: true → fall back to memory_search |
memory_relation_define(name, description, ...) |
Grow the closed relation vocabulary (deliberate, rare act) |
document_ingest(path, source?) |
Index a file (txt/md/pdf) in the reference bank |
document_search(query, top_k?) |
RAG search over the reference bank only |
memory_toolset(action) |
Check or change this session's visibility tier: status / expand / collapse (see "Toolset tiers" below) |
Each tool returns plain JSON. See pseudolife_memory/mcp_server.py for
docstrings — those are what Claude reads to decide when to call which tool.
Compact results. The five recall-path tools (memory_search,
memory_recall, memory_recent, memory_world_search,
memory_lesson_search) return compact entries by default — for the
associative stream that's {id, text, source, tags, score} plus the
supersession signal when set — because result payloads are agent context on
every retrieval. Pass verbose=true (or explain=true on memory_search)
for the full metadata: timestamps, counters, band/episode attribution, and
fact/edge provenance. Cortex Console REST responses are unaffected.
Full-table dumps and topology views (facts, world, lessons, sources,
tags, episodes list, graph digest/communities, shortest path, session
briefing) live in the Cortex Console (/api/*) and the
pseudolife-mcp briefing CLI — they left the MCP surface in the 2026-07-02
consolidation.
Toolset tiers. Three visibility tiers — minimal (7 tools: the
recall/capture loop + the gate), core (20: + graph/recall, world facts,
lessons, documents, episodes), full (33) — filtered per session at
tools/list; hidden tools stay callable by name. Defaults:
PSEUDOLIFE_MCP_TOOLSET (shipped: core) sets the baseline;
PSEUDOLIFE_MCP_TIER_MAP="claude-desktop:minimal,claude-code:core" sets
per-client defaults by writer id. Any session can step its own tier up or
down at runtime with memory_toolset(action="expand"|"collapse"|"status")
— the daemon emits tools/list_changed, and clients that ignore it can
still call the tools named in the result. Eager-loading clients (Claude
Desktop) start at ~1.5k tokens of manifest on minimal; clients that
defer schemas client-side (Claude Code) barely notice tiers at all.
Architecture
One memory daemon owns the bank and serves MCP over streamable HTTP
at /mcp; every Claude Code session (and any LAN agent) attaches to it.
Postgres 16 + pgvector (in Docker) is the durable source of truth —
the in-memory MIRAS bands are a write-through cache hydrated at startup
(a small weights.pt persists only band counters — there are no MLP weights).
The daemon runs either containerized (recommended — portable, no host Python) or as a host process. Claude Code attaches either directly over HTTP (recommended) or through a thin torch-free stdio shim:
Claude session A ─┐ HTTP (recommended)
Claude session B ─┼───────────────────► pseudolife-mcp daemon ─► Postgres (Docker)
LAN agent ────────┘ or stdio shim (single writer) pgvector
(per session) host proc OR Docker
This kills two v0.1 hazards by construction: a single writer means concurrent sessions can't clobber each other, and entries are transactional so a crash can't wipe the bank (only the retrainable weights cache rides the periodic save).
Knowledge graph (ontology-lite)
The cortex's canonical facts are joined to a typed entity graph
(Postgres mode only). Edges use a closed relation vocabulary —
builtins depends-on, part-of, runs-on↔hosts, uses,
configures, stores-data-in, related-to (* = transitive) — so a
weak model can't fragment the graph with depends_on/dependsOn
variants: common forms normalize automatically, true unknowns are
rejected with suggestions. Soft type hints warn but never reject.
Transitive closure and inverse mirroring are computed on read by
NetworkX inside memory_graph; derived edges arrive marked
derived: true with rule provenance, so multi-hop conclusions read as
plain facts — the server reasons, the model reads.
The graph store is Postgres entities hub as source of truth, with a
NetworkX derived read-model built on demand — behind a swappable GraphStore
interface. There is no AGE/Cypher dependency; memory_graph serves
multi-hop queries (neighborhood + derived/inverse edges + shortest path).
Weak-model deployments: set PSEUDOLIFE_MCP_TOOLSET=core — it exposes the
curated core set and hides the power/hygiene tools (memory_forget,
memory_relation_define, memory_dream, memory_graph_review, …) that a
small model can misuse.
memory_recall (multi-hop retrieval)
memory_recall(query, hops=3, top_k=5) answers relational questions
by iteratively following the knowledge graph — things memory_search
can't do with a single flat similarity pass.
When to use it vs memory_search:
- Use
memory_recallfor chain-of-links questions: "what does X ultimately run on?", "where does Y's data end up?", "how does A reach C?". - Use
memory_searchfor direct lookups: "what is X's port?", "what did I decide about Y?" — those are flat similarity queries andmemory_searchis faster and simpler.
How it works. memory_recall searches for a seed entity in the query,
then walks its graph neighbourhood one hop per iteration (up to hops,
capped at 5), accumulating bridging entities, facts, edges, and paths. It is
read-only — it never writes to the bank or the graph.
Return shape:
seeds, entities (each with current canonical facts), edges (with a
derived flag for inferred transitive/inverse links), paths, supporting
texts, and iterations.
low_confidence: true means no seed entity matched the query — the graph
had no starting point. In that case fall back to memory_search.
Driver config. By default memory_recall uses the mechanical seed
driver (token-intersection heuristic — no LLM call, deterministic, fast).
Set PSEUDOLIFE_RECALL_DRIVER=llm to use the dream endpoint for seed
resolution (better recall on ambiguous entity names; requires the dream
extractor to be configured).
Install — containerized (recommended, any OS)
The whole stack — Postgres and the memory daemon — runs in Docker.
No host Python, no torch install, no version skew; the daemon image bakes
in CPU-only torch and the all-MiniLM-L6-v2 weights, so it runs
identically on Windows / macOS / Linux. Requires only Docker (~2.5 GB
daemon image + the Postgres image, built once).
git clone https://github.com/Pseudogiant-xr/PseudoLife-MCP.git
cd PseudoLife-MCP
# 1. One-time: create the two persistent volumes (bank + daemon state).
docker volume create pseudolife-mcp-bank
docker volume create pseudolife-mcp-state
# 2. Build + start all three services (Postgres, extractor, then the daemon).
docker compose -f ops/docker-compose.yml up -d --build
Upgrading from a pre-rename install (volumes
ops_pseudolife_pgdata/ops_pseudolife_data)? Don't rename those volumes — keep pointing at them by creatingops/.envwithPSEUDOLIFE_BANK_VOLUME=ops_pseudolife_pgdataandPSEUDOLIFE_STATE_VOLUME=ops_pseudolife_databeforeup. See the compose header.
Windows + Docker memory. Docker Desktop's WSL2 backend (the
Vmmem/VmmemWSLprocess) defaults to claiming up to ~50% of host RAM and caches aggressively without releasing it. The stack itself needs only ~2–4 GB resident, so cap the VM: copyops/wslconfig.exampleto%USERPROFILE%\.wslconfig(tunememory=to your machine), thenwsl --shutdownto apply. After awsl --shutdown, if the daemon becomes unreachable on127.0.0.1:8765, rundocker restart pseudolife-mcp-daemonto re-establish the host port forward.
The daemon serves MCP at http://127.0.0.1:8765/mcp and restarts with
Docker — no logon task needed. First build downloads the model into the
image (once); every container start after that is offline and fast. Wire
Claude Code in over HTTP (below).
Install — host process (Windows, for GPU / dev)
Runs Postgres in Docker but the daemon on host Python. Use this if you want to hack on the daemon or run the embedder on a local GPU. Requires Python 3.10+, Docker Desktop, and ~600 MB of disk.
git clone https://github.com/Pseudogiant-xr/PseudoLife-MCP.git
cd PseudoLife-MCP
python -m venv .venv
.venv\Scripts\activate
pip install -e .
# 1. Start Postgres 16 + pgvector (one-time build, then persistent).
docker compose -f ops/docker-compose.yml up -d --build pseudolife-pg
# 2. Register the daemon to auto-start at logon (binds 127.0.0.1:8765).
ops\install-autostart.ps1
Start-ScheduledTask -TaskName "PseudoLife-MCP Daemon"
The pseudolife-mcp console-script is now on your PATH with four modes:
pseudolife-mcp serve (the daemon), pseudolife-mcp (the stdio shim —
auto-starts the daemon if absent), pseudolife-mcp embedded (the
v0.1 in-process stdio server; no daemon, no Postgres — an escape hatch), and
pseudolife-mcp briefing (print the session-start briefing; used by the hook).
Updating
After a git pull (or local code change), redeploy the daemon only — safely,
without touching Postgres or the extractor:
.\ops\update.ps1 # Windows
./ops/update.sh # Linux / macOS
It backs up the bank (pg_dump + a state-volume tar), tags a rollback image
(keeping the newest 2, older ones are pruned), rebuilds + recreates only
the daemon (docker compose up -d --no-deps --build pseudolife-daemon), and
waits for /health. It never runs down -v. (Host-process install: just
restart the daemon — pip install -e . is editable, so a restart picks up the
new code.)
Repeated --build deploys also accumulate Docker build cache; reclaim it
now and then with docker builder prune (safe — it only touches build
layers). Never use docker system prune --volumes, which deletes volumes.
Wire into Claude Code
HTTP transport (recommended — required for the containerized stack). The daemon already serves MCP over HTTP, so point Claude Code straight at it — no shim, no host command, nothing OS-specific. One command:
claude mcp add --transport http --scope user pseudolife-memory http://127.0.0.1:8765/mcp
(--scope user registers it for every project; drop it to register for the
current project only.) Or write the equivalent JSON yourself — into
~/.claude.json under the top-level mcpServers key for user scope, or into
a .mcp.json at a project root for project scope:
{
"mcpServers": {
"pseudolife-memory": {
"type": "http",
"url": "http://127.0.0.1:8765/mcp"
}
}
}
If you ran the daemon with a PSEUDOLIFE_MCP_TOKEN, add the bearer header:
{
"mcpServers": {
"pseudolife-memory": {
"type": "http",
"url": "http://127.0.0.1:8765/mcp",
"headers": { "Authorization": "Bearer <your-token>" }
}
}
}
This is the cleanest cross-OS setup: the only host-side state is this URL.
Verify: run claude mcp list (the server should report ✓ connected),
then ask Claude to "store a memory that this install works" and check it
appears in the Stream tab of the Console at http://127.0.0.1:8765/ui/.
stdio shim (host-process installs only). If you run the daemon on host Python and prefer stdio, point at the shim instead — it find-or-starts the daemon and proxies. It does not work with the containerized daemon (nothing to spawn on the host):
{
"mcpServers": {
"pseudolife-memory": {
"command": "C:\\path\\to\\PseudoLife-MCP\\.venv\\Scripts\\pseudolife-mcp.exe",
"env": {
"PSEUDOLIFE_MCP_DAEMON_URL": "http://127.0.0.1:8765",
"PSEUDOLIFE_MCP_DATABASE_URL": "postgresql://pseudolife:pseudolife@127.0.0.1:5433/pseudolife_memory",
"PSEUDOLIFE_MCP_DATA_DIR": "${USERPROFILE}\\.pseudolife-mcp"
}
}
}
}
Replace C:\path\to\PseudoLife-MCP with wherever you cloned the repo. The
PSEUDOLIFE_MCP_DATABASE_URL matches the bundled ops/docker-compose.yml
defaults (user/password pseudolife, host port 5433) — change it only if you
edit the compose file or override the password. The default password is safe
for the stock loopback-only stack (nothing off-box can reach Postgres); to use
your own anyway, set POSTGRES_PASSWORD in ops/.env before the first
launch (see the note in ops/docker-compose.yml for changing it later).
The shim is torch-free, so sessions attach near-instantly; the daemon
pays the one-time embedder warmup once for everyone. On first run with a
v≤0.1 cms_state.pt present in PSEUDOLIFE_MCP_DATA_DIR, the daemon
auto-migrates it into Postgres and renames the originals *.pre-v8.bak
(never deletes them).
Sharing memory on the LAN: run the daemon with
PSEUDOLIFE_MCP_HOST=0.0.0.0 and a PSEUDOLIFE_MCP_TOKEN; remote
clients set the same PSEUDOLIFE_MCP_DAEMON_URL + PSEUDOLIFE_MCP_TOKEN.
The daemon refuses to bind a non-loopback host without a token, and
Postgres itself stays loopback-only — the LAN only ever sees the daemon.
Backups: ops\backup.ps1 (Windows) / ops/backup.sh (Linux/macOS) runs
pg_dump inside the container into data\backups\ with 7-day rotation, and
also tars the daemon state volume (ingested document_ingest files,
cortex snapshot, graph snapshots — those live only there, not in Postgres)
into a sibling pseudolife_state-*.tgz. An optional off-disk mirror via
PSEUDOLIFE_BACKUP_MIRROR carries both artifacts;
PSEUDOLIFE_BACKUP_MIRROR_KEEP=N (or -MirrorKeep / --mirror-keep) caps
the mirror at the newest N files per kind — handy for cloud-synced folders.
The matching restore script rehearses the newest backup into a scratch
database by default (never touching the live bank) and only replaces the live
bank with an explicit -Apply / --apply; add
-StateArchive <pseudolife_state-*.tgz> / --state-archive to also restore
the state volume (opt-in, so a DB-only restore never clobbers current state).
Recommended agent setup (CLAUDE.md)
The server's value depends entirely on the agent using it well — this step
is what makes the memory loop actually fire; installs that skip it end up
with a healthy daemon whose tools are never called. Encode the loop as a
standing instruction: append the bundled block to your global
~/.claude/CLAUDE.md (applies to every project) or a per-project CLAUDE.md
/ AGENTS.md:
cat examples/CLAUDE.memory.md >> ~/.claude/CLAUDE.md
Add-Content "$env:USERPROFILE\.claude\CLAUDE.md" (Get-Content examples\CLAUDE.memory.md -Raw)
Treat memory as RECALL at the start, CAPTURE as you go, REFLECT at the end (session episodes open/close for you via the lifecycle hooks above). The block:
## Memory — use it every session (tools: `mcp__pseudolife-memory__*`)
RECALL at task start:
- `memory_search(<task>)` for prior context/decisions/gotchas;
`memory_lesson_search(<task>)` for what worked / what to avoid (heed `polarity:-`);
`memory_fact_get(entity, attribute)` for one canonical value;
`memory_world_search(<topic>)` when an external fact may be stale.
CAPTURE as durable things arise (one claim per call):
- `memory_store` for durable context (set `origin`: user/action/agent);
`memory_fact_set` for a canonical single-value fact (correct by re-setting the slot);
`memory_world_set(..., source_url=, source_quote=)` for a verified EXTERNAL fact (cite it);
open a named sub-episode with `memory_episode_start` for a big multi-step task.
Route verbose status/logs under `source="status"` (searchable, but excluded from
the dream so they don't pollute the graph).
REFLECT at task end / when an outcome lands:
- `memory_outcome(task, outcome, about=, detail=)` for a success / dead-end / correction —
the dream distils these into the lessons surfaced at your next session start.
The same block ships as a copyable file:
examples/CLAUDE.memory.md.
The dream pass periodically distils stored memories into canonical facts and a
knowledge graph; the Console's Atlas view (/api/graph/digest,
/api/graph/communities) then surfaces the graph's shape (hubs, communities,
surprising links, questions worth answering).
Session lifecycle hooks (recommended)
Two things wire to Claude Code's session lifecycle so the memory loop runs reliably — without the agent having to remember:
- SessionStart briefing.
pseudolife-mcp briefingprints a compact block: what your memory is unsure about (surprising graph links + open questions), lessons from past work (avoid / prefer), verified world facts (fresh, cited, age-ranked), and where we left off (a one-line recap of your last closed session). Empty sections are omitted, so a cold bank injects nothing. - Episode lifecycle is owned by the daemon — no hooks required. Each
memory_storeis stamped to its session's episode, keyed by a stable per-session id: the transport'smcp-session-idfor a direct-HTTP client (the shipped path — stable for the whole session), or a stdio shim'sX-PL-Session. Because a direct-HTTP client has no shim/hook in the path, the daemon lazily opens a session episode on the first store of a new session (so empty sessions never leave a husk) and an idle reaper closes it once inactive — firing the end-of-session dream, or pruning it if empty (PSEUDOLIFE_SESSION_IDLE_SECONDS, default 30 min). One open episode is tracked per session, so concurrent sessions (e.g. different projects) never clobber each other. (Earlier versions drove this fromSessionStart/SessionEndepisode hooks keyed by Claude's session id; those are obsolete. The legacypseudolife-mcp episode-start/-endCLI + shim path remain for stdio clients.) A store arriving after the reaper closed the episode resumes it — same session id, same episode — rather than opening a new husk (PSEUDOLIFE_SESSION_RESUME_SECONDS, default 6 h;0disables). Direct-HTTP titles start generic (session - YYYY-MM-DD HH:MM, since the daemon has no projectcwd) — name the session withmemory_session_title(store responses carry anepisode_hintuntil you do); a session closing still-generic gets an auto-derived"{dominant source} - {stamp}: {first-entry snippet}"title. Fragmented history is repairable over REST:POST /api/episodes/renameandPOST /api/episodes/merge. SetTZinops/.envfor local time.
One command installs the briefing hook:
.\ops\install-hook.ps1 # Windows (PowerShell 7)
./ops/install-hook.sh # Linux / macOS
It backs up your settings.json, then adds the hook alongside any existing
ones (idempotent — safe to re-run; it installs only what's missing). Requires
pseudolife-mcp on PATH — pip install -e . in the repo puts it there.
Prefer to wire it by hand? The briefing's --hook-json flag emits the
hookSpecificOutput.additionalContext payload Claude Code injects:
{
"hooks": {
"SessionStart": [
{ "hooks": [
{ "type": "command", "command": "pseudolife-mcp briefing --hook-json" }
] }
]
}
}
The briefing connects to the already-running daemon (never starts one) and
does nothing if the daemon is down — it can't slow or break session start.
Tune the briefing budget with --max-unsure N / --max-lessons N /
--max-world N (default 3 each). The briefing content is also available on
demand via the CLI or the Console's /api/briefing route.
Configuration
Connection / deployment env vars:
| Variable | Default | Effect |
|---|---|---|
PSEUDOLIFE_MCP_DATABASE_URL |
(unset → file mode) | Postgres DSN; when set, PG is the source of truth (schema v22). Unset → v0.1 file-only mode. |
PSEUDOLIFE_MCP_DAEMON_URL |
http://127.0.0.1:8765 |
Daemon the shim connects to (and auto-starts). |
PSEUDOLIFE_MCP_HOST / _PORT |
127.0.0.1 / 8765 |
Daemon bind address. |
PSEUDOLIFE_MCP_TOKEN |
(unset) | Bearer token; required to bind a non-loopback host. |
PSEUDOLIFE_MCP_TRUST_BIND |
(unset) | Set 1 to allow a non-loopback bind without a token when the boundary is external (containerized, loopback-published). The compose daemon sets this; never set it for a host daemon. |
PSEUDOLIFE_MCP_DATA_DIR |
./data (cwd-relative) |
Weights cache + legacy-migration source + ChromaDB. |
PSEUDOLIFE_MCP_CONFIG |
<data_dir>/config.yaml if present, else built-ins |
Override MIRAS / embedding / memory config. |
PSEUDOLIFE_WRITER_ID |
unknown |
Identifies this writer on every canonical write (schema v11). The shim forwards it as the X-PL-Writer header; the compose daemon sets claude-code. |
The built-in defaults are tuned for Claude's use case:
- Surprise threshold
0.0— the v0.5 store gate measures novelty (1 − max costo existing entries). Claude stores deliberately, so the gate stays permissive (store everything; novelty still drives eviction/promotion scoring). Raise it above zero to dedup near-duplicate stores. - Meta-filter off (
memory.meta_filter.enabled = falsein the MCP build) — the filter exists to drop auto-captured chat noise ("I don't have anything saved about that"); every MCP store is a deliberate tool call, and the filter's patterns collided with legitimate dev facts about memory systems themselves. - Recency base half-life 24h (
memory.recency_base_half_life_s = 86400, vs the 1h chat default) — Claude Code sessions are hours-to- days apart; with a 1h half-life the recency boost was effectively always zero. Halves per band depth as before (1d → 2d → 4d → …). - MIRAS preset
continuum— the 8-tierworking / micro / instant / fast / medium / slow / archival / forevercontinuum. Bands are plain cosine vector stores (v0.5); a band spec is capacity + consolidation cadence + promotion thresholds + an eviction policy. - No NLI scorer — the
cross-encoder/nli-deberta-v3-xsmallcontradiction model is ~278 MB and optional. The four-path detector works without it. Install withpip install .[nli]if you want it. - Cross-encoder reranker off — the
ms-marco-MiniLM-L-6-v2reranker (~80 MB) is wired into the pipeline but disabled by default. Flip it on either globally (memory.reranker.enabled = truein config) or per-call (memory_search(..., rerank=True)). First call lazy-loads the model from the HuggingFace hub; subsequent calls cost ~10ms per reranked candidate. Details below under Cross-encoder reranking. - BM25 hybrid lexical pool off — a pure-stdlib BM25 sparse-retrieval
channel runs in parallel with the dense embedder when enabled, fusing
scores so exact-keyword queries (
process_chunk_v2,v0.7.6, error codes) still surface even if the embedder underweights them. Off by default; flip viamemory.bm25.enabled = trueormemory_search(..., bm25=True). Details below under BM25 hybrid retrieval. - Abstention off (
memory.search_confidence_floor = 0.0) — set it above zero andmemory_searchreturnslow_confidence: truewhenever the top match scores below the floor, so the agent can abstain instead of answering from a weak hit. A cortex fact in the result always overrides it — but which cortex facts count is now tunable viamemory.cortex.guard_min_score(default0.2; a LongMemEval retrieval replay showed the old0.3floor served zero facts for 60% of questions, because terse fact embeddings rarely score 0.3 against a natural-language query even when they are the answer — while going below 0.2 measurably hurt by diluting the context with weak facts): only facts scoring at/above it are treated as a confident answer, so weak topically-adjacent facts stop suppressing abstention. The two are calibrated as a pair; theevals/sweep recommendsguard_min_score = 0.65+search_confidence_floor = 0.70for an abstention-on deployment (doubles abstention recall at zero false-abstain). - Dream slot resolver off (
memory.cortex.dream_slot_match_threshold = 0.0) — a positive cosine floor lets the dream pass map a paraphrased(entity, attribute)onto an existing slot before writing, to catch small-model supersession forks. ⚠️ Calibration found no measurable benefit on the benchmark (stale-leak flat; a false-merge at0.80): the residual fragmentation comes from the deterministic regex auto-promote, not paraphrase. Left off; enable only with the false-merge risk in mind. Seedocs/specs/2026-06-19-single-writer-cortex-design.mdfor the structural fix. - No HyDE / no reflection — both rely on an LLM callback. Claude is
the LLM, so the natural way to reflect is for Claude to call
memory_storewith a self-composed summary.
Usage patterns
At session start:
memory_search("project context for X")
Loads what you've worked on before, persistent across compactions.
During work:
memory_store("Decided to use stdio transport for the MCP because no port conflicts", source="pseudolife")
Stores a real decision. Skip fleeting chatter — the surprise gate will drop near-duplicates anyway.
When corrected:
memory_supersede(
"Provider interface uses synchronous calls",
"Provider interface uses async calls — sync version was the v0.7 prototype only"
)
Marks the old fact superseded and stores the correction. Both will surface in future retrieval, with the new one ranked higher.
Discovering what's in the bank: open the Cortex Console — sources, tags,
episodes, and full-table views all live there (/api/sources, /api/tags,
/api/episodes, …). Band tensors autosave on a cadence and flush on clean
exit; there is no manual save step.
Debugging a retrieval miss:
memory_search("why didn't X come back?", sources=["pseudolife"], explain=True)
Returns the normal search result plus a trace dict: every
tier's candidates with raw_score, recency boost, source/supersession
multipliers, and the drop_reason (or kept=True) for each. The
final_topk block shows exactly which entries reached the result set
and what score they carried.
Also useful for state-probe queries where recency bias is unwelcome:
memory_search("current Python version", disable_recency_boost=True)
Hygiene:
memory_forget(scope="memory", source="test-noise")
memory_forget(scope="memory", substring="Junk entry")
memory_forget(scope="fact", entity="test-entity")
At least one filter is required for scope memory — a bare call returns
an error to prevent accidental wholesale deletion. For "keep the history
but mark it wrong" use memory_supersede instead.
Cross-encoder reranking (Tier B):
memory_search("which python testing framework do we use", rerank=True)
After the bi-encoder retrieval builds the top-N candidate set, run
cross-encoder/ms-marco-MiniLM-L-6-v2 over each (query, candidate)
pair and fuse the resulting relevance score with the bi-encoder score:
final = fusion_weight * sigmoid(ce_score) + (1 - fusion_weight) * original
The default fusion_weight = 0.7 leans on the cross-encoder but
preserves enough of the bi-encoder signal that recency / source /
supersession multipliers still nudge order on near-ties. Off by
default — enable per call with rerank=True, or globally via:
memory:
reranker:
enabled: true
model_name: cross-encoder/ms-marco-MiniLM-L-6-v2
top_n: 20 # rerank the top-N candidates only
fusion_weight: 0.7 # 1.0 = pure CE, 0.0 = pure bi-encoder
First call lazy-loads the ~80 MB model from the HuggingFace Hub; later calls cost ~10 ms per reranked candidate on CPU (≈ 200 ms wall-clock added to a top-20 search). If the model fails to load, the reranker disables itself silently and retrieval falls back to bi-encoder ranking — search never breaks because of an optional component.
memory_search(..., rerank=True, explain=True) surfaces the per-candidate
original_score, ce_score, and fused_score under trace.reranker
so you can see exactly how the cross-encoder reshuffled the
bi-encoder ordering.
BM25 hybrid retrieval (Tier B2):
memory_search("process_chunk_v2", bm25=True)
memory_search("ship blocker for v9.42.0", bm25=True)
Dense MiniLM-L6 embeddings are great for semantic similarity but can underweight tokens with no real semantic neighbours — function names, version strings, error codes, hex hashes. BM25 is the classic sparse-lexical scorer (Okapi BM25 with Lucene-style IDF) that weights tokens by inverse document frequency, so rare-but-exact tokens count for a lot. The BM25 pool runs in parallel with dense retrieval and fuses with weighted score-sum:
final = dense_score + weight * normalized_bm25_score
Entries already in the dense pool get boosted; entries only BM25
found enter at weight * normalized_bm25 (intentionally below a
typical dense hit so semantic recall still drives ordering). The
tokenizer keeps underscored identifiers and dotted version strings
whole, lowercases everything, and filters a tiny stop list.
Configure globally with:
memory:
bm25:
enabled: true
k1: 1.5 # term-frequency saturation
b: 0.75 # length-normalisation
weight: 0.3 # contribution to the fused score
top_n: 20 # how many BM25 hits to consider
min_score: 0.1 # floor on normalised BM25 (drops noise)
No new dependencies — pure stdlib. Cost is one O(N tokens) index rebuild per query, ≈ 20-50ms on a 40K-entry bank.
memory_search(..., bm25=True, explain=True) records per-hit raw_bm25,
normalized, and any BM25-only injections under trace.bm25.
Episodes + tags (Tier C):
An episode is a bracketed working session. While an episode is open, every
memory stored carries the episode's id + title automatically, so later queries
can scope by session. Session episodes open and close for you, daemon-owned
and keyed by a stable per-session id (the transport mcp-session-id, or a shim's
X-PL-Session) so concurrent sessions don't collide; the daemon lazily opens one
on first store and an idle reaper closes it. For a substantial multi-step task you
open a nested sub-episode under the session:
memory_episode_start("auth refactor") # nests under the open session
memory_store("Decided to keep tags orthogonal to source instead of merging them")
memory_episode_end() # pops back to the session
memory_search("design choices", episodes=[session_id]) # expands to the subtree
memory_episode_summary(session_id) # stats + tag distribution + recent entries
Episodes nest (schema v15): memory_episode_start opens a child under the
current open episode — the parent stays open — memory_episode_end pops back to
it, and closing the session cascade-closes any still-open children. A
session-scoped memory_search(episodes=[root_id]) expands to the whole subtree,
so a sub-episode's entries surface under their parent session too. (Calling
memory_episode_start with nothing open simply opens a root.) In Postgres mode
episodes live in the episodes table (session_key + parent_id columns); in
file mode they ride cms_state.pt under the episodes key.
Tags are a parallel multi-valued axis to source: pass
tags=["decision", "blocker"] on store, filter with
memory_search(..., tags=[...]). Normalised at store time (lowercased,
stripped, deduped). Set intersection non-empty for the filter to pass
(OR within the filter list, AND with the other filters).
Consolidation workflow (Tier C):
Long-running banks accumulate near-duplicate memories — the same fact phrased five different ways across five sessions. The literature on agent memory (HiMem 2026; MIRIX 2024; the ICML 2025 position paper) calls consolidation — turning episodes into reusable semantic notes — the most-important under-implemented capability of long-term LLM memory.
PseudoLife-MCP can't run an LLM inside the server (Claude Code doesn't yet expose MCP sampling — see feature request #1785). But it can surface clusters for Claude to consolidate manually:
memory_consolidation_candidates(query="MCP transport choice", top_k=20)
# → {clusters: [{cohesion: 0.84, size: 3, members: [<entry>, ...]}, ...]}
memory_consolidate(
replaces=["MCP uses stdio transport", "stdio was chosen for MCP", "decided on stdio for MCP"],
new_text="MCP transport is stdio — chosen over TCP to avoid port conflicts.",
tags=["consolidated"],
)
# → {superseded_count: 3, new_memory_stored: true, ...}
The clustering is deterministic greedy: highest-relevance entry seeds
the cluster, any unclustered candidate whose cosine with the seed
clears min_cohesion (default 0.6) joins, cohesion is the mean
intra-cluster cosine, clusters are sorted by cohesion × size. Cost
is O(N²) within the candidate pool, bounded to top_k candidates.
memory_consolidate reuses the supersession machinery so the
predecessors stay in the bank but rank below the canonical note —
the audit trail survives but retrieval defaults to the current
phrasing. Useful idiom: tag the consolidation with ["consolidated"]
so you can later scan with memory_search(..., tags=["consolidated"])
to see what's been distilled.
Canonical facts — the cortex (schema v8)
Alongside the associative continuum (the 8 MIRAS bands) sits the cortex: a
slot-keyed canonical-fact store. Where the continuum is similarity-ranked and
decaying, the cortex is identity-not-similarity, supersession-not-decay,
currency-not-frequency — one current value per (entity, attribute) slot,
retrievable out of the context window.
- Single-writer capture. The LLM dream pass (the extractor sidecar) is the
sole automatic writer of canonical facts, plus deliberate
memory_fact_setcalls. The deterministic regex auto-promote onstoreis now opt-in (memory.cortex.auto_promote, default off): it mis-splits compound entity names ("payments database host"→payments/database host) and fragments slots, so it ships off — seedocs/specs/2026-06-19-single-writer-cortex-design.md. (When enabled it still uses the precision-first dev lexicon:<entity> <attr> is <value>with the attribute drawn from a closed set — port / version / host / branch / default timeout / … — plusmy <attr> is <value>,<Entity>'s <attr> is <value>,the <attr> of <entity> is <value>, and single-line<entity> <attr>: <value>.) A one-timeops/dedup_cortex.py(dry-run-first, reversible) collapses sibling slots left by past auto-promotes. - Deterministic read.
memory_fact_get("project", "language")returns the one current value — no ranking, no stale duplicates.memory_searchalso surfaces matching facts ahead of associative hits (a"cortex"block). - Deliberate write / correction.
memory_fact_set(entity, attribute, value, origin="user")asserts a fact at higher confidence; setting a new value at an existing slot supersedes the old (kept as audit history).
Provenance contenders — never silently overwrite a user fact
Every cortex fact carries a provenance tier: user > action > agent
(set via origin=, or defaulted from source). A write may only supersede a
slot whose current value is backed by an equal-or-weaker tier. A weaker-tier
write (e.g. an agent value conflicting with a user-stated fact), or one below
the confidence margin, is not applied — it's parked as a contender:
memory_fact_set("db", "host", "10.0.0.5", origin="user") # current
memory_fact_set("db", "host", "10.0.0.9", origin="agent") # -> action="contested"
# current stays 10.0.0.5; "10.0.0.9" is parked. memory_fact_get shows both;
# memory_search flags the fact "contested": true.
memory_fact_resolve("db", "host", accept=True) # human said yes -> adopt (user-confirmed)
# or accept=False -> discard the contender, current unchanged.
This catches the case where the agent decides to update something and the human
only said "yes/proceed": the discrepancy surfaces (at the write, in search, and in
memory_fact_get) so the agent can check in rather than overwrite. Set
memory.cortex.protect_provenance: false in config.yaml to disable and restore
pure newer-wins.
World knowledge — the world cortex (schema v9)
A third layer sits beside the personal cortex: the world cortex, for durable
facts about external reality that a frozen training cut-off may have wrong or
stale — a current model version, a price, who holds a role, a research finding.
It's a separate slot-keyed store (its own world_facts table, origin=source),
so external claims never mingle with the user/project facts.
memory_world_set("anthropic", "latest-model", "opus-4.8",
source_url="https://...", source_quote="Opus 4.8 is the latest...",
freshness_class="volatile") # weeks | "slow" months | "evergreen" never
memory_world_search("which Claude model is current")
# → entries with effective_confidence (age-decayed), a `stale` flag, and the citation
Each fact carries a citation (source_url + the 1–2 sentence source_quote,
not the whole page) and a freshness_class that drives age-decayed trust at
read time: past 2×TTL a fact is flagged stale (a lead to re-verify, not truth).
The trust contract: prefer a fresh, cited world fact over frozen training
intuition when they conflict — but cite it ("as of , per ") rather
than presenting it as your own knowledge; your own cortex/episodic facts stay the
highest-trust ground truth. memory_search surfaces matching world facts in a
separate block, and the Console's world view (/api/world) lists them all for audit.
The world cortex here is populated manually via
memory_world_set. The live-webresearch_ingestaction (fetch + distil cited world facts automatically) is an agent-side capability that depends on the agent's web tool — it is not part of the standalone MCP server.
Procedural memory — the lessons store (schema v10)
A fourth layer learns from the agent's own work. Where the cortex stores
declarative facts ("X is Y"), the lessons store is procedural: keyed by a
task-type and an aspect (approach / pitfall / tool-choice /
correction), each lesson carries an outcome (success / failure /
correction) and a polarity (+ do-this / - avoid). Its own lessons
table keeps it isolated from the personal and world cortex.
Capture is cheap and in-session; synthesis is single-writer (the dream):
# during a task, log what happened — this writes a SIGNAL, not a lesson:
memory_outcome("deploy engine to host", "failure",
about="tar --same-owner", detail="chown errors aborted the extract")
memory_outcome("deploy engine to host", "success", about="tar --no-same-owner")
# user corrections are auto-captured when a user-tier memory_fact_set supersedes a value.
# the dream later distils accumulated signals into durable lessons; recall them at task start:
memory_lesson_search("how do I deploy the engine to a host")
# → [{task, aspect, lesson, about, polarity:"-"|"+", outcome, confidence, score}, ...]
Lessons are also traversable in the graph: a task-type becomes an
etype='task-type' entity, and each lesson adds a prefers (positive) or
avoids (negative / dead-end) edge to the tool/source it concerns — so
memory_graph("deploy engine to host") shows what to reach for and what to
avoid. Retrieval is embedding-on-query (mirrors memory_world_search); the
graph edges power structured traversal.
Single-writer:
memory_outcomeonly ever logs a signal — the dream's LLM extractor is the sole writer of lessons. With no extractor configured, signals accumulate (pruned by retention) and no lessons are synthesised, exactly as the cortex behaves without an extractor. The synthesised lessons are auto-injected at session start by thepseudolife-mcp briefingSessionStart hook (the "lessons from past work" block) — see Session lifecycle hooks.
Sense of time + multi-writer attribution (schema v11)
Every canonical write (cortex, world, lessons) now carries a temporal / provenance stamp so the agent has a real sense of when a fact held and who set it — and so concurrent writers can't silently clobber each other:
tx_time— when this version was written (wall-clock display).valid_time— when the fact became true (event time). A lesson synthesised from an outcome signal inherits the signal's observation time, not the dream's write time, so the two clocks stay honest (bitemporal).(hlc_phys, hlc_logical)— a Hybrid Logical Clock that is the ordering authority for supersession. Wall clocks can jump backwards (NTP steps, clock skew across sessions); the HLC is monotonic, so "newer wins" is jitter-proof — a later write always supersedes, even if its wall time reads earlier. Wall time is display-only.writer_id/session_id— which writer/session made the change. The daemon reads anX-PL-Writerheader per request (the stdio shim forwardsPSEUDOLIFE_WRITER_ID) and mints a per-connection session id, so a Codex session, a second Claude session, and the dream are all distinguishable.
Reads surface this: serialised facts include the stamp plus a human age
("3 days ago"), and memory_history(entity, attribute) returns the full
version timeline — current + superseded, oldest→newest, each attributed. The
supersession log records the writer/session too.
Writer topology. The live path is a single daemon with a coarse lock (
write_mode=snapshot) — correct by construction. The schema also lays a dormantwrite_mode=occseam (aversioncolumn + per-row compare-and-swap) for a future multi-process writer; selecting it raisesNotImplementedErroruntil that Phase-2 path is built.Collision fix (v0.4) + AGE removal. The DB role is
pseudolife; the old Apache AGE graph was also namedpseudolife, which made AGE create apseudolifeschema that shadowed the realpublicbank. AGE has since been removed entirely — edges live in the relationaledgestable (the source of truth), so the collision can no longer recur.ops/migrate_drop_age.pydrops the AGE graph + extension from an existing bank (back up first), and every connection still pinssearch_pathtopublic(asserted on startup).ops/retire_by_writer.pysupersedes a rogue writer's rows in one shot.
Dreaming — consolidating memories into facts
A dream distils the recent associative stream (MIRAS) into canonical cortex
facts: pull unconsolidated memories → extract (entity, attribute, value) →
memory_fact_set → advance a monotonic cursor so each memory is processed once.
Because it keys on the cursor, not on "sessions", returning to an old session
later just appends more tail — nothing is reprocessed, and there is no
"session finished" event to detect.
Extraction is pluggable; pick the tier that fits — no self-hosted model is required:
| Tier | How it runs | Needs | Quality |
|---|---|---|---|
| 0 — baseline | memory_dream(action="run") (regex floor) — headless, on-box, free |
nothing | weak (X is Y, key: value, port/version) |
| 1 — default | the agent itself is the gateway: the /dream command |
the agent you already run | highest |
| 2 — opt-in | daemon auto-sweep calls a configured OpenAI-compatible endpoint | one base-URL + key + model | high; free if local |
Tier 1 — /dream (recommended). Copy examples/commands/dream.md to
.claude/commands/dream.md in any project, then run /dream. The agent reads
memory_dream(action="pull"), extracts durable current-state facts, writes them
with memory_fact_set, and commits the cursor. To run it on a cadence instead
of by hand, point a scheduled agent/cron job at the same prompt.
Tier 0 — zero-config. Call memory_dream(action="run") (or schedule it)
for a fully headless pass with the deterministic regex floor — no LLM, nothing
leaves the machine.
Tier 2 — headless auto-sweep. Point the daemon at any OpenAI-compatible endpoint and it dreams on its own — no agent, no manual trigger:
$env:PSEUDOLIFE_DREAM_BASE_URL = "http://localhost:11434/v1" # e.g. Ollama
$env:PSEUDOLIFE_DREAM_MODEL = "qwen2.5:7b"
# $env:PSEUDOLIFE_DREAM_API_KEY = "sk-..." # hosted endpoints (Haiku, OpenRouter, ...)
# $env:PSEUDOLIFE_DREAM_TIMEOUT_SECONDS = "240" # raise for a slow CPU / big model (default 240)
# $env:PSEUDOLIFE_DREAM_MAX_TOKENS = "2048" # extractor output budget (default 2048)
The daemon runs a background sweep every memory.dream.sweep_interval_seconds;
each tick it checks the same backlog+quiescence trigger and, if it fires, runs a
dream with the configured extractor. Under the single-writer cortex a successful
pass that finds no canonical facts writes nothing and advances the cursor; a
failed call (timeout, network, malformed output) instead holds the cursor,
so those memories are retried next sweep rather than skipped — there is no regex
fallback either way. The extractor timeout defaults to 240s in code; the
Docker stack ships 480s (PSEUDOLIFE_DREAM_TIMEOUT_SECONDS in the compose
file) because the default E4B sidecar generates at ~12–15 tok/s on CPU, so a
full PSEUDOLIFE_DREAM_MAX_TOKENS generation runs ~150–170s — raise it further
for slower hardware. The same env vars also upgrade memory_dream(action="run").
A local model keeps all text on-box; a hosted endpoint does not.
Tier 2, batteries-included — the CPU extractor sidecar (default-on). The stack
ships a llama.cpp sidecar with a model baked in (the bespoke Gemma 4 E4B
extractor fine-tune, ~5.3 GB — see "Upgrading the extractor" below for the
lighter E2B bake), and ops/docker-compose.yml starts it by
default and routes dream consolidation to it. It's internal-only (never published
to the host). Single-writer cortex relies on it: with no extractor configured, the
cortex is populated only by memory_fact_set and the daemon logs a startup
warning. Reasoning models work too — the extractor disables their <think> trace
so they return structured output instead of an empty budget. The evals/
extractor-ladder benchmark is how the default was chosen (even the smallest
bake, Gemma 4 E2B, beats naive-RAG at ~25× fewer tokens/query); see
evals/README.md.
Upgrading the extractor — bigger local models. If you have a GPU (or a beefier box on your LAN), any OpenAI-compatible server can replace the sidecar — the ladder measured a Qwen3.6-27B on a single RTX 4090 at the quality ceiling (gold 1.0 / stale-leak 0.0) while extracting ~5× faster than the CPU sidecar. Two ways to switch:
From the Console (no restart): the Extractor panel in the Cortex
Console's config view edits the endpoint, model, timeout, and token budget
live — flip its "Settings source" switch to config first (while it is
env, the default, the PSEUDOLIFE_DREAM_* variables below own the
settings and the panel's values are ignored). The API key stays env-only
either way.
Via env: for the Docker stack, set the override in ops/.env (the compose
file interpolates it into the daemon) and restart the daemon
(docker compose -f ops/docker-compose.yml up -d --no-deps pseudolife-daemon):
# ops/.env — point dream consolidation at a local model server.
# From inside the container the host machine is host.docker.internal, NOT
# localhost (works on Linux too via the extra_hosts entry shipped in
# ops/docker-compose.yml).
PSEUDOLIFE_DREAM_BASE_URL=http://host.docker.internal:1234/v1
PSEUDOLIFE_DREAM_MODEL=qwen3.6-27b
Per-runtime defaults (all serve the same /v1/chat/completions shape):
| Runtime | Typical base URL (from the container) | PSEUDOLIFE_DREAM_MODEL |
|---|---|---|
| LM Studio | http://host.docker.internal:1234/v1 |
the model's API identifier shown in LM Studio's server tab |
| Ollama | http://host.docker.internal:11434/v1 |
the tag, e.g. qwen2.5:14b |
llama.cpp (llama-server) |
http://host.docker.internal:8080/v1 |
anything (single-model server ignores it) |
| vLLM | http://host.docker.internal:8000/v1 |
the --served-model-name |
| LAN box | http://192.168.x.x:PORT/v1 |
per the runtime above |
The unused sidecar can be stopped (docker compose -f ops/docker-compose.yml
stop pseudolife-extractor) or left running as a fallback to switch back to.
The default bake is the bespoke PseudoLife extractor fine-tune
(Gemma 4 E4B QLoRA); constrained machines can bake the lighter Gemma 4 E2B
QAT instead (also ladder-verified) — see the MODEL_URL build-arg
in ops/Dockerfile.extractor, or mount any GGUF over /models/extractor.gguf
via a machine-local ops/docker-compose.override.yml (gitignored; example in
the compose file). If you run the daemon outside Docker (embedded
stdio mode), the $env: variables above apply directly and localhost URLs
work as-is. A local or LAN model keeps all memory text on your network; the
same env triple pointed at a hosted endpoint does not.
Optional: Sonnet primary with local fallback. With a Claude Max plan, the
dream pass can use Claude Sonnet as its primary extractor and keep the bundled
local sidecar as an automatic fallback. The installer does all of this in one
go — ops/install.sh --extractor sonnet-fallback (or sonnet-only to skip
the sidecar entirely; ops\install.ps1 -Extractor ... on Windows). The manual
steps:
- Register the CLI shim (
evals/sonnet_shim.py) to start automatically — requires a logged-inclaudeCLI:- Windows:
ops\install-shim-autostart.ps1(Task Scheduler, at logon,127.0.0.1:8082). - Linux:
ops/install-shim-autostart.sh(systemd--userunit; binds the docker bridge IP so the daemon container can reach it —host-gatewayroutes container→host traffic to the bridge, where a loopback bind is invisible).
- Windows:
- Set in
ops/.env(both vars must flip together — pointing only one at the shim leaves dreams silently on the sidecar):PSEUDOLIFE_DREAM_BASE_URL=http://host.docker.internal:8082/v1,PSEUDOLIFE_DREAM_MODEL=extractor,PSEUDOLIFE_DREAM_FALLBACK_BASE_URL=http://pseudolife-extractor:8081/v1,PSEUDOLIFE_DREAM_FALLBACK_MODEL=extractor,PSEUDOLIFE_DREAM_EXTRACTOR_MODE=auto(orprimary/fallbackto force a side — also switchable live in the Console's Extractor panel). - Redeploy (
ops/update.ps1/ops/update.sh), then verify:memory_dream(action="status")should showfallback_urlpopulated and, with the shim up,primary_healthy: true; after the next dream,last_dream_extractor.whichshould readprimaryagainst the:8082URL. The daemon also logs a startup warning for the common half-configurations (unresolvablehost.docker.internal,autowithout a fallback, primary == fallback).
When the shim is unreachable or the CLI is logged out, dreams automatically
use the fallback; the Console's Observatory shows which extractor is active.
Leave PSEUDOLIFE_DREAM_FALLBACK_BASE_URL unset to keep the existing
single-extractor behavior.
What gets consolidated and when is configurable under memory.dream
(eligible_sources / exclude_sources, and the min_batch / idle_seconds
backlog+quiescence thresholds that memory_dream(action="status") reports).
Cadence — quiescence-gated, daemon-only. The auto-sweep (Tier 2) fires when:
backlog ≥ min_batch (8) OR (backlog ≥ 1 AND idle ≥ idle_seconds (600s))
polled every sweep_interval_seconds (600s). It runs only in the daemon — the
embedded stdio mode never sweeps. There is no turn-based trigger (the cortex
does not "dream every N turns"), by design: consolidating mid-session would distil
half-formed, still-changing state into canonical facts and burn the CPU extractor
during your foreground work. So during an active session, prose-stored facts stay
in the searchable bands and reach the cortex once you go quiet (~10 min idle) or a
backlog of 8 accumulates.
Want a fact canonical now, mid-session? Two on-demand paths bypass the wait:
memory_fact_set writes a canonical fact instantly, and memory_dream(action="run")
forces a full consolidation sweep on the spot (the /dream command wraps it).
memory_search finds the original prose the entire time regardless.
Privacy & cost. Tier 0 is on-box and free. Tier 1 spends the agent tokens you already pay for (a scheduled daily dream is small but non-zero). Tier 2 with a cloud endpoint sends memory text off-box — a local model (e.g. Ollama) keeps it on-machine.
Deep dream — full-corpus graph consolidation. The incremental dream (tiers
above) is window-local: it distils only the recent MIRAS tail into cortex facts.
memory_dream(action="deep") is a separate, manually-triggered full-corpus GRAPH
pass (Phase-2 'C'). A dry-run (default) returns a preview of what it would change:
re-scored edges, hard type-violation edges queued for supersession, exact-duplicate
entity pairs queued for merging, and semantic link candidates across sessions
(each with truncated context snippets; items the apply path would dedupe are
flagged already_proposed). Adding apply=True first dumps the five graph
tables to a JSON undo file under data_dir/graph_snapshots/ (refusing with
snapshot_failed if it can't), then commits the safe self-clean (re-score +
supersede violations + merge exact dups) and returns candidates for review.
The agent then drives Step C in the same session (see the /dream deep flow in
examples/commands/dream.md): judge each candidate from its snippets, post the
real relations with memory_graph_review(action="propose") — they land in the
Atlas Review queue (proposed_link findings) for per-item accept/reject before
anything reaches live edges — and record clearly-distinct pairs with
memory_graph_review(action="dismiss_pair") so they stop resurfacing. See
docs/runbooks/deep-dream.md for the operator procedure.
Data layout
Containerized / daemon mode (recommended). The durable source of truth is
Postgres, which lives in an external Docker volume — pseudolife-mcp-bank
by default (entries + facts + graph). A second external volume,
pseudolife-mcp-state, holds the daemon's ChromaDB reference bank, the
band-counter weights.pt, and the cortex snapshot. Both are declared external
in ops/docker-compose.yml precisely so a container teardown can't take them
with it. The host data/ dir then holds only backups (data/backups/ from
ops/backup.ps1 — a pg_dump of the bank plus a tar of the state volume)
and one-time legacy-import staging — not the live bank.
To wipe the bank in this mode you must drop those volumes deliberately —
never docker compose down -v or docker volume rm without ops/backup.ps1
first; stop / start and up -d --build keep both volumes.
File mode (no daemon / no Postgres — the embedded CLI, or unset
PSEUDOLIFE_MCP_DATABASE_URL). Everything lives under PSEUDOLIFE_MCP_DATA_DIR:
data/
├── memory_state/
│ └── cms_state.pt # 8-tier MIRAS entries + metadata (file mode)
├── cortex_state.pt # Slot-keyed canonical facts (cortex, schema v8)
├── chromadb/ # Reference bank (RAG documents)
└── config.yaml # Optional overrides
In file mode only, wipe memory by deleting data/ and restarting; wipe just
documents via data/chromadb/; wipe just the episodic bands via
data/memory_state/. (In containerized mode these files are not the source of
truth — see the volume note above.)
Troubleshooting
Start with curl http://127.0.0.1:8765/health — it reports the schema
version, storage backend, auth state, and persist_errors (non-zero means
writes are failing to reach Postgres; check docker logs
pseudolife-mcp-daemon).
- First build is slow / big. The daemon image bakes in CPU torch and the embedding model (~2.5 GB, several minutes). Every start after that is offline and fast — if a rebuild is re-downloading models, the Docker layer cache was pruned.
- Daemon unreachable after
wsl --shutdown(Windows): the host port forward is gone —docker restart pseudolife-mcp-daemonre-establishes it. - Docker eating RAM (Windows): the WSL2 VM (
Vmmem) claims up to ~50% of host memory by default. Copyops/wslconfig.exampleto%USERPROFILE%\.wslconfig, tunememory=, thenwsl --shutdown. - Port already in use: the stack binds
127.0.0.1:8765(daemon) and127.0.0.1:5433(Postgres). Change the host side inops/docker-compose.ymlif either collides. - Console shows "offline" / Unauthorized: "offline" means the daemon
isn't reachable (see above). A 401 prompt means the daemon runs with
PSEUDOLIFE_MCP_TOKEN— paste the same token into the Console's Token dialog. - Claude Code doesn't see the tools:
claude mcp listshould showpseudolife-memory✓ connected. If not, re-check the URL (http://127.0.0.1:8765/mcp— the/mcppath matters) and the bearer header when a token is set. - A tool call hangs on first use: the first call after a cold start loads the embedder (a few seconds, once per daemon start). The session-start briefing hook never blocks — it skips silently when the daemon is down.
Uninstall
Deletion is deliberate at every step — nothing here is reversible past the backups you keep:
# 1. Optional: take a final backup first (ops/backup.ps1 or ops/backup.sh).
# 2. Stop and remove the containers (volumes survive this).
docker compose -f ops/docker-compose.yml down
# 3. Remove the MCP registration.
claude mcp remove pseudolife-memory
# 4. Only when you're sure: delete the data volumes (THIS is the memory).
docker volume rm pseudolife-mcp-bank pseudolife-mcp-state
Host-process installs: also unregister the logon task (Unregister-ScheduledTask
-TaskName "PseudoLife-MCP Daemon") and remove the SessionStart/SessionEnd
hooks that install-hook.ps1 / install-hook.sh added to
~/.claude/settings.json (a timestamped .bak-* sits next to it).
Testing
.venv\Scripts\activate
pip install -e .[dev]
pytest tests/ -v
The suite covers every layer: the MemoryService surface, retrieval scoring
(cross-encoder reranker / BM25 hybrid), the cortex + world cortex + lessons
stores, episodes + tags, the dream extractors (including the regression that
a failed extraction holds the cursor so memories are retried, not
skipped), the knowledge graph (+ insight layer and multi-hop recall),
Postgres storage (schema idempotency, write-through consistency, legacy
.pt migration, concurrent-writer safety), the HTTP daemon + stdio shim,
the MCP tool surface (schema + end-to-end invocation for every exposed
tool, plus a description-budget guard that keeps the manifest lean), and
the Cortex Console REST API. Model-heavy pieces are stubbed where possible
so the suite stays fast and offline.
The PG-backed suites target a throwaway pseudolife_memory_test database on
the bundled dev container (ops/docker-compose.yml, port 5433) — created on
first run and reset per-test, so repeat runs stay green and never touch your
real bank. Point them elsewhere with PSEUDOLIFE_TEST_DATABASE_URL. With the
container up, pytest tests/ runs everything; without any Postgres, the PG
suites skip and the pure-logic suites still pass.
Benchmarks
Measured on the knowledge-update subset of LongMemEval (78 questions) — the "user's facts change over time" ability the HLC supersession spine exists for. Everything local: extraction, answering, and LLM-as-judge grading all run on the author's own hardware (judge = Qwen3.6-27B at temperature 0), so compare within the table, not against GPT-4o-judged leaderboards.
On the oracle variant (evidence sessions only), with the local-ceiling extractor:
| arm | accuracy | context tokens/question |
|---|---|---|
| naive RAG (top-6 turns) | 0.615 | 1638 |
| cortex facts only | 0.564 | 59 |
| hybrid (facts + top-3 turns) | 0.705 | 979 |
The consolidated-facts posture beats naive RAG by 9 points while reading
~40% of the context — and the fact spine alone delivers 92% of RAG's
accuracy on 3.6% of its token budget. Running floor (Gemma 4 E2B, the
smallest CPU-sidecar bake) vs ceiling (Qwen3.6-27B) extractors with the RAG arm
as a fixed control isolates extraction quality as the dominant factor
in fact-spine accuracy — the measured case for the "Upgrading the
extractor" section above. Full methodology, the harder full-haystack
(_s) results, and every finding: evals/README.md.
Cortex Console (web UI)
An operator dashboard served by the daemon itself — point a browser at
http://127.0.0.1:8765/ui/ (the /health and /mcp endpoints are
unchanged; the console is additive). It's a read-mostly instrument panel for
seeing and steering the memory a human otherwise can't observe.
- Observatory — health, per-layer counts, the 8-band MIRAS continuum (capacity fill + hit rate), and dream backlog/quiescence gauges.
- Cortex — canonical facts grouped by entity with provenance tiers and
confidence; click a fact for its version-history timeline; contested
slots surface inline with Accept/Discard (
memory_fact_resolve). - World / Lessons / Episodes — cited external facts (freshness + decayed trust), do/avoid procedural lessons, and the session timeline with summaries.
- Stream — live search with rerank/BM25 toggles, the associative stream, and a ranking-trace debugger (why an entry did/didn't surface).
- Graph — an interactive force-directed visualiser of the knowledge graph (drag, click-to-expand, derived vs explicit edges) with a table view.
- Console — the config "knobs & dials": every safe scalar in
config.yamlwith a description, live-vs-restart badge, diff-preview, and an atomic save (timestamped backup; live knobs apply in-process).
Auth mirrors /mcp: /ui (static shell) and /health are open; /api/*
requires the same PSEUDOLIFE_MCP_TOKEN bearer when one is set (the console
prompts for it and stores it locally). No build step, no CDN, fully offline —
vanilla ES modules + vendored OFL fonts served straight from the daemon.
Developing the UI: a fixture-backed dev server (no Postgres, no torch) renders the real frontend against canned data for fast iteration:
python -m pseudolife_memory.web.devserver # http://127.0.0.1:8770/ui/
Capabilities at a glance
| Capability | Status |
|---|---|
| Transport | MCP stdio shim → HTTP daemon |
| Storage | Postgres 16 + pgvector (source of truth); ChromaDB for the reference bank |
| Associative continuum | 8-tier cosine MIRAS bands, novelty-gated storage, contradiction detection, supersession |
| Canonical-fact cortex | Single-writer: LLM dream pass + memory_fact_* (regex auto-promote opt-in, default off) |
| Provenance contenders | Tier-rank guard user > action > agent; memory_fact_resolve |
| Knowledge graph | Typed entities/edges, closed relation vocab, on-read closure (Postgres + NetworkX, no AGE/Cypher) |
| World cortex | memory_world_* — cited external facts + age-decayed freshness (manual ingest) |
| Procedural memory | memory_outcome (signals) → dream-synthesised lessons via memory_lesson_search; prefers/avoids graph edges; single-writer |
| Sense of time + multi-writer | Per-write stamp (tx/valid time, HLC ordering, writer/session); memory_history; relative age on reads; write_mode seam (snapshot live, occ Phase-2) |
| Episodes + tags | Session episodes daemon-owned, keyed by stable per-session id (mcp-session-id / X-PL-Session); lazy-open + idle reaper + prune-empty; nested sub-episodes (memory_episode_*, schema v15) with subtree-expanded recall; multi-valued tags=[...] |
| Session briefing | SessionStart hook injects unsure-graph + lessons + verified world facts + last-session recap (pseudolife-mcp briefing) |
| Consolidation | memory_consolidation_candidates + memory_consolidate |
| Cross-encoder reranker | Optional (rerank=True per call, ~80 MB; reranker.skip_margin skips the pass when the top-2 bi-encoder gap is decisive) |
| ONNX embedding backend | Optional (pip install .[onnx], ~90 MB extra weights) — ~3x faster CPU encode, bit-identical embeddings; auto-enabled when installed, embedding.backend overrides |
| BM25 hybrid pool | Optional (bm25=True per call, stdlib only) |
| NLI contradiction scorer | Optional (pip install .[nli], ~278 MB) |
| Web console | Cortex Console at /ui/ — health/stats, fact review + history, graph visualiser, search/trace, config editor (read-mostly, token-gated like /mcp) |
| Schema version | v22 (Postgres meta version) — v11 temporal/provenance stamp, v12 graph-insight communities, v13 provenance-trace engram + reinforcements, v14 episode session_key, v15 episode parent_id (nesting), v16 entity_sources (per-entity project attribution), v17 edge_proposals (deep-dream link candidates), v18 entity_proposals (deep-dream merge/junk candidates), v19 partial unique indexes enforcing one current row per slot on facts/world_facts/lessons (+ startup heal of pre-existing duplicates; per-slot write-through persistence replaces the full-table snapshot rewrite), v20 dismissed_pairs (reviewed-distinct pairs stop resurfacing as duplicate findings), v21 merge_decisions audit + write-time near-duplicate merge proposals, v22 edges(dst_id) index (dst-side graph lookups no longer sequential-scan); additive ADD COLUMN IF NOT EXISTS on daemon start; legacy file-mode .pt banks auto-migrate into Postgres |
What's not built yet
- Reflection via MCP sampling — the MCP protocol has a
samplingcapability that lets servers ask the client (Claude) to generate text. Wiring that up would bring the periodic-reflection feature back without needing a bundled LLM. Claude Code doesn't yet support sampling — until it does,memory_consolidation_candidates+memory_consolidategive Claude the same outcome through manual tool calls. - Cross-machine sync — memory lives on one PC's disk. Syncing
data/via rclone / syncthing / git-lfs is left as an exercise. - Hierarchical summarisation — periodic auto-summaries at multiple time scales (daily, weekly). Mostly subsumed by Tier C's episode + consolidation flow; what's left is the cadence automation.
- Automated world-knowledge ingestion — the world cortex stores and serves
cited external facts, and the SessionStart briefing now surfaces them (the
verified world facts block), but populating it from the live web
(
research_ingest) still needs a web-fetch tool the standalone server doesn't ship. Today, assert world facts withmemory_world_set(an agent with web access can automate the fetch+cite step); the read surface is already in place.
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pseudolife_mcp-0.8.0.post1.tar.gz.
File metadata
- Download URL: pseudolife_mcp-0.8.0.post1.tar.gz
- Upload date:
- Size: 1.2 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0cc8d8ab2e12d5849cf2542f1fc8f88433e71bcf489a23f2de4e286640feb3d4
|
|
| MD5 |
bd5f97e51d34f5a6862d9576ad66d812
|
|
| BLAKE2b-256 |
60f7ce3e396cee026a2ec4d08b7f11ba8621f51ce75d6e00e9b8d345ca945b2d
|
File details
Details for the file pseudolife_mcp-0.8.0.post1-py3-none-any.whl.
File metadata
- Download URL: pseudolife_mcp-0.8.0.post1-py3-none-any.whl
- Upload date:
- Size: 970.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
325cfc7b624049700185398deb4e76c4121efd9530737164d2337414b2b487c2
|
|
| MD5 |
9ebcc041bfc165c3d4c5d240bfbbcf72
|
|
| BLAKE2b-256 |
4707bfd48d5f20422454852fe65786698574813a6141489b5063b0f1e21225d7
|