Back to Directory/Developer Tools

io.github.jarmstrong158/context-keeper

Durable project-memory MCP: decisions, constraints, and pipelines across Claude sessions.

Developer ToolsPythonv0.19.0

Context Keeper

Part of the xylem stack.

Project memory for Claude. Records design decisions, pipeline flows, and constraints so Claude maintains context across conversations.

The Problem

As conversations get long, Claude loses the "why" behind earlier decisions. New conversations start blank. This causes Claude to make changes that break established patterns — like rewriting a pipeline step it doesn't remember exists.

The Solution

Context Keeper gives Claude 14 tools to record and retrieve structured project context:

ToolPurpose
record_entryUnified write tool — record a decision, pipeline, or constraint via kind, with per-kind fields validated server-side. Consolidates the former record_decision/record_pipeline/record_constraint (still dispatchable by those names for back-compat)
get_contextRetrieve relevant entries by query, tags, scope, or ID — relevance-ranked, pulls related_to links by default
query_entriesExact structured-field filtering (status, origin, tags, scope, hardness, supersession, dates) — deterministic, no ranking; distinct from get_context's relevance search
get_project_summaryCompact overview for conversation start
update_entryUpdate any entry by ID
deprecate_entryRetire an entry with reason (optional merge_into folds a duplicate into a survivor)
prune_staleFind entries not verified recently
get_compaction_reportCheck if last compaction lost any context
verify_qualityScan entries for thin rationale, missing tags, isolated arcs (auto-called by PreCompact hook)
export_markdownRegenerate DECISIONS.md from the decisions store — a derived, read-only projection
reload_constraintsRe-surface the constraints-only block on demand mid-session (rules refresh, not the full store)
export_snapshotWrite the whole store to a committable .context-keeper/memory.json.gz for sharing project memory via git
import_snapshotImport that committed snapshot into the working store — non-destructive, auto-runs on first use when the store is empty
mirrorSync with the optional remote store: op="pull" merges remote→local (newest wins), op="backfill" pushes local→remote. No-op if the remote is unconfigured

All data stored as human-editable JSON files in .context/ inside your project directory. Zero dependencies by default, semantic retrieval optional.

How this relates to Claude Code's built-in memory

Claude Code ships two memory mechanisms of its own: CLAUDE.md files you write by hand, and auto memory, where Claude saves freeform notes to ~/.claude/projects/<project>/memory/. Context Keeper is not a replacement for either — it sits on different ground, and the differences are the reason to run it:

Auto memoryContext Keeper
ShapeFreeform markdown, an index plus topic filesTyped entries (decision / pipeline / constraint) with server-validated fields
DepthWhatever Claude writesSchema-enforced: problem ≥40 chars, why_chosen ≥60, thin entries rejected at capture
LifecycleEdit or delete the filesupersedes (demoted, still recallable), deprecate, merge_into, drift + staleness scans
ConflictsNoneRestatement vs contradiction classified at capture, with origin-based trust precedence
RetrievalIndex loaded whole each sessionRelevance-ranked within a token budget, with an abstention signal on no-answer queries
ReachMachine-local; explicitly not shared across machinesCross-device via the mirror, team-shared via a committed snapshot

They compose rather than compete: auto memory is good at picking up incidental preferences with zero effort, and Context Keeper is for the decisions and rules you want structured, queryable, enforceable, and portable. Running both is fine — and with rules_export enabled, Context Keeper writes into the harness's own .claude/rules/ surface rather than around it.

Known gap, stated honestly: subagents do not inherit the main conversation's session-start injection, so a subagent starts without the project summary. If your workflow leans on subagents, have them call get_project_summary explicitly — the retrieval-is-unskippable property holds for the main loop only.

Capabilities at a glance

Context Keeper is a small, offline-first memory layer; several of its capabilities are easy to miss because they live inside existing tools rather than as separate features. The map below names them in memory-system terms:

CapabilityHow Context Keeper does it
Procedural memoryrecord_entry(kind="pipeline") stores ordered, dependency-aware workflows (build/deploy/data flows) with purpose + when_to_invoke — reusable "how we do X", not just facts.
DeduplicationEvery record_* runs a word-set Jaccard pass against the store and returns similar_entries when a new entry restates an existing one, so duplicates are caught at capture; deprecate_entry(merge_into=...) then folds the duplicate's unique content into the survivor and retires it in one non-destructive step.
Contradiction detectionThose same overlaps are classified likely_restatement vs likely_contradiction (negation/antonym polarity), and a reversal raises a contradiction_note telling the agent to resolve the conflict rather than leave two live rules disagreeing.
Quality refinementverify_quality scans for thin rationale, missing tags, legacy-schema entries, and isolated (unlinked) arcs; the PreCompact hook runs it automatically so entries get enriched before context is compressed.
Supersede / decay / forgetsupersedes demotes-but-keeps prior decisions (recallable history); prune_stale surfaces unverified entries for review; deprecate_entry removes an entry from retrieval entirely.
Origin + trust / source attributionEvery entry records origin (user / agent / import); retrieval gives user-stated entries a trust boost and it decides the default winner when entries conflict.
Anticipated queriesretrieval_hints stores alternate phrasings a future session might search for, so vocabulary-mismatch queries hit without embeddings.
Hybrid retrievalLexical (tag + word overlap) by default; an opt-in embedding-cosine blend (semantic.enabled) adds vector recall, with lexical fallback when the embedder is offline.
Fact-metadata queryquery_entries filters entries by exact predicates over structured fields (status, origin, tags-any/all, scope, hardness, supersession, dates), AND-combined and deterministic — a precise lookup path distinct from get_context's fuzzy relevance ranking.
Cache-friendly injectionThe session-start memory block is deterministically ordered with a stable prefix and the only per-session-volatile line (quality-scan IDs) emitted last, so an unchanged store injects byte-identical text across sessions.
Path-triggered rulesScoped constraints project into Claude Code's own .claude/rules/*.md format with paths: frontmatter (rules_export), so the harness loads a rule when the agent reads a covered file — before an edit, with no hook involved. The scope_guard PreToolUse hook covers the write path for clients without rules support.
Narrative + clusteringget_project_summary clusters decisions by topic above a threshold and renders a compact narrative; the DECISIONS.md projection mirrors the store as human-readable prose.
Data export / offline / privacyPlain JSON in .context/ you can read, edit, grep, and commit; runs fully offline with zero required dependencies and no data leaving the machine.

Evaluation & benchmarks (open methodology)

The retrieval and honesty properties are measured, not asserted — the harness is in evals/ and reproducible with no network required:

  • Token reduction — session-start injection vs. dumping the full store: 97.3% / 94.1% / 85.5% / 73.3% across four real stores (evals/token_reduction.py). The meaningful property is that injected cost stays roughly flat as the store grows.
  • Retrieval quality — 59 cases over a frozen 7-store corpus, every question written from the problem an entry solves rather than paraphrasing its summary. The opt-in semantic blend lifts recall@5 from 0.42 → 0.64 and MRR 0.37 → 0.55 (evals/run_retrieval_eval.py). These are lower than the figures published before 2026-08-05 (hit@5 80% → 93%) and that is the point: the old set was partly paraphrase-derived, so queries shared surface tokens with their targets and lexical recall came out flattered. The gain is concentrated in large, prose-heavy stores; small stores are already at 1.000 lexically.
  • Abstention — measures whether get_context says "nothing relevant" instead of confabulating on no-answer queries; the 0.20 relevance floor is the highest with zero false-abstention on the eval set (evals/abstention.py).

Every dataset, metric, and caveat is checked into the repo — see evals/README.md. The corpus is frozen under evals/fixtures/corpus, so the numbers reproduce on any clone and a regression test can pin them; --live runs against the real stores when you want a current read instead of a comparable one.

v0.19: Supersession as a Signal, and One Rule for Scope

  • The store could say what was true, and not what changed. It held 36 entries, 36 active, 0 deprecated, 0 supersedes links — while dec-013 stated it replaced the old additive-only sync and con-006 carried "(was additive-only)" inline. deprecate_entry had supported superseded_by since v0.10 and it had never been used once: nothing asked at the moment the answer was known, and nothing spent the link afterwards.

    record_* now names active same-kind entries covering the same subject — possible supersession of <id>: <summary> — scored on shared tags and whole-component scope overlap, deliberately not the text overlap similar_entries already computes (a replacement often shares almost no wording with what it replaces while addressing exactly the same thing). It never links, never blocks the write, and never touches the older entry: a heuristic edge silently demotes a rule that may still be in force.

    get_context prepends one compact line for the immediate predecessor — what it said and why it changed — because a link is only worth making if retrieval spends it. One level deep; under budget pressure the line is dropped and a predecessor_id trail is left, never the entry. The remote Worker emits the identical line, so the two transports cannot diverge.

    scripts/survey_supersessions.py proposes backfill links read-only — it opens no store for writing and calls no lifecycle tool.

  • con-011 was false. It requires every surface deciding "does this scope cover this file" to agree; there were five surfaces with four implementations, two of which provably disagreed, and score_entry still used the raw substring test the rule exists to forbid — so querying scope="hooks/" gave a webhooks/-scoped entry the same boost as the real one. scope_rules.py is now the only implementation, importing nothing so the PreToolUse hook can load it under con-010.

  • Retrieval has a number, and it holds still. A 59-case golden set with negatives and history cases, plus a regression test pinning the lexical arm. The first pin broke within a day without a line of ranking code changing — it read live stores, so recording three entries moved it. The corpus is frozen now.

  • Internals: server.py 4,270 → 3,787 lines (ranking.py, mojibake.py, quality_checks.py, all re-exported); verify_quality's seven inlined checks became a registry; the test suite split from one 5,213-line file into six themed ones.

v0.17: Closing the Delivery Gaps

v0.16 got a rule in front of the model before the edit. Measuring real stores afterwards showed two holes that compound, and one that had been open since the beginning.

  • Truncated entries are no longer undiscoverable. The SessionStart hook prints the summary text and nothing else, so an entry dropped for budget wasn't merely unsummarised — the agent had no way to learn it existed in order to go ask for it. On the largest real store that was 116 lines of memory silently gone. The summary now ends with the ids it dropped, paid for inside the same budget (bare ids cost a fraction of the entries they stand for) and emitted in stable store order so the prompt-cache prefix stays byte-identical. summary_dropped_ids carries the same list structurally.

    The constraints floor still wins over the budget when it has to — one real store's 19 constraints are 2429 tokens against a 2000 budget — because losing the rules is worse than overspending. It is reported, never silent.

  • global scope is now a flagged quality issue. 47% of constraints across real projects were global, which excludes them from the drift check, the .claude/rules/ projection and scope_guard — leaving the truncating summary as their only route to the model. Those two problems multiply. verify_quality now flags global_scope, but only when it can name a concrete path from the entry's enforced_by or a tag matching a real directory. A bare "consider adding a scope" on every global rule is noise the reader learns to skip, and some rules genuinely are global.

  • Subagents get the project rules. SessionStart does not fire for subagents and they do not inherit the parent's injected context, so every subagent has been starting with no project memory — on a fan-out, a dozen contributors who never read the rules. The new subagent_start.py hook injects the constraints-only block via SubagentStart. Constraints only, and capped: this fires once per spawned agent, so a fan-out multiplies it.

    "SubagentStart": [
      { "hooks": [{ "type": "command",
                    "command": "python /path/to/context-keeper/hooks/subagent_start.py",
                    "timeout": 5 }] }
    ]
    
  • A release can no longer half-fail in silence. v0.16.0 published to PyPI and the MCP registry, then the bundle workflow failed on manifest validation — and a release missing an asset is indistinguishable from one that never had it. A new verify-release workflow asserts all three channels actually received the version, and the manifest is now validated on every PR rather than at release time.

  • constraint_reinject came off the heavy import path. Its matcher is "", so it runs after every tool call — the hottest hook in the set — and it was importing server to format a list of strings. Constraint rendering moved to store_paths, so it and subagent_start both run within ~5ms of Python's own startup floor. One implementation still, shared by every surface.

v0.16: Path-Triggered Rules, and the Rule Before the Edit

Both halves of this release come from reading Anthropic's own memory documentation and asking which of its mechanics context-keeper was leaving on the table.

  • Scoped constraints project into .claude/rules/*.md (opt-in). Claude Code loads a rule file carrying paths: frontmatter when it reads a matching file — earlier than any hook can fire, and with no hook wired at all. rules_export.enabled mirrors every scoped constraint into .claude/rules/context-keeper/, one file per scope, on the write path of every constraint mutation. Same contract as the DECISIONS.md projection: JSON stays canonical, the markdown is derived, regenerated whole, and never parsed back in.

    The projection owns a subdirectory rather than .claude/rules/ itself, and reaps only files carrying its own generated marker — so a deprecated constraint's rule file disappears on the next write, while a hand-written rule dropped in the same folder is never touched. The marker is a block-level HTML comment, which Claude Code strips before the file enters context: it identifies the file to the tool and to a human reader at zero token cost.

    A scope is refused rather than emitted when it can't become a pattern that actually fires: glob metacharacters ([ ] { } * ?), because Claude Code reads [ as a bracket expression and one that never closes matches nothing; and a double quote or control character, because each pattern is emitted as a double-quoted YAML scalar and an embedded quote closes it early, killing the whole frontmatter block — the harness then loads no rule from that file, not even the patterns that were fine. Refused scopes come back in skipped_scopes, because a rule file that silently never fires is worse than no rule: it looks like coverage.

    rules_export.path is contained to the project. This directory is not merely written to, it is reaped — every marker-carrying .md file in it is a deletion candidate on each regeneration — so a ../../.. or absolute path in config would aim that deletion at a directory the user never associated with context-keeper. Containment is also just correct: Claude Code only discovers .claude/rules/ inside the working tree, so an outside directory could never load anyway. A path that escapes is refused with an error, and the constraint write itself still succeeds — projections are derived, the JSON store is canonical, and a misconfigured projection must never fail a record.

  • scope_guard now runs under PreToolUse. It fired on PostToolUse, which means the rule arrived after the write had already landed — a review note, not a guardrail. PreToolUse additionalContext is injected next to the tool result, so the constraint reaches the model while it can still act on it. One script serves either wiring: it reads hook_event_name and answers with the matching hookEventName, defaulting to PostToolUse when the field is absent, so existing installs keep working unchanged and upgrading is a config edit rather than a rewrite.

    Opt-in scope_guard.confirm_absolute escalates a PreToolUse hit on an absolute constraint to permissionDecision: "ask", pausing for the user instead of only annotating. Default off. The honest limit, stated plainly: this escalates on scope, not on violation — nothing here reads the diff, so it cannot know the edit actually breaks the rule.

  • The edit path got a latency budget it can't quietly lose. Moving to PreToolUse means the hook runs before the tool, so its cost lands on the critical path of every Edit and Write rather than trailing them. Measured on Windows, the hook cost ~142ms, of which ~73ms was import server — mirror pulling in urllib.request → http.client → email.parser, plus secrets, usage, code_drift, none of which this hook touches. Worse, a path matching no constraint paid the same 142ms as a hit, and that is most edits.

    Store location and raw reads now live in store_paths.py, which imports json and os and nothing else. server.py imports its resolution from there rather than defining its own, so the precedence order (env var → Xylem pointer → cwd → parent walk) has exactly one implementation — a second copy would drift, and the copy that drifts is the one nothing executes.

    ~142ms → ~69ms, against a ~62ms bare-interpreter floor. The remainder is Python process startup, which a shell hook cannot avoid. TestEditPathHookCost now fails if an edit-path hook imports server again, or if store_paths grows an import beyond json/os — the property is pinned, not just fixed. Wire the hook with "timeout": 5 (shown below) to bound the pathological case; nothing else caps a hook that hangs.

  • Over-budget summaries are now floored and reported. Truncating the session-start summary popped lines from the end, and with a small enough budget it walked straight through the constraints block and emptied the summary — the v0.9 failure (a healthy-looking store injecting nothing) in a different guise. Trimming now stops at the constraints block, and the response carries summary_truncated / summary_lines_dropped / summary_truncation_note when the cap bit. Those keys appear only when truncation happened, so the untruncated common case stays byte-stable for the prompt cache. Anthropic's auto-memory index errors rather than silently dropping content past its read limit; same principle.

  • The store can now report — and repair — its own encoding damage. con-008 fixed the cause of mojibake in v0.11: stdin defaulted to cp1252 on Windows, so a client's raw UTF-8 bytes were mis-decoded before json.loads ever ran and an em-dash landed in the store as â€". Forcing UTF-8 on the transport meant no new entry was corrupted. It did nothing for entries already written, and nothing ever looked.

    That damage is invisible in the worst possible way: the text stays legible enough that nobody re-reads the entry, so a corrupted rationale quietly degrades every retrieval that surfaces it. verify_quality now flags it as mojibake, and a repair_mojibake handler fixes it:

    context-keeper repair_mojibake '{}'                # dry run, writes nothing
    context-keeper repair_mojibake '{"apply": true}'   # repair
    

    The repair is the exact inverse of the corruption and is verified as one — re-applying the corruption to the candidate must reproduce the input byte for byte, and anything failing that check is left alone. A partial repair of someone's recorded reasoning is worse than legible damage, because it looks fixed. verified_at is deliberately not refreshed: an encoding fix is not a claim that anyone re-confirmed the entry is still true, and resetting the staleness clock would erase the signal prune_stale and the drift check exist to raise. updated_at is bumped, so the corrected copy wins the mirror's newest-wins merge rather than being overwritten by a corrupt remote.

Enable the projection in .context/config.json and backfill once:

{ "rules_export": { "enabled": true } }
context-keeper export_rules '{}'

export_rules is deliberately not in tools/list — con-004 caps the schema payload every client pays for at session start, and this is a one-time backfill. Render-on-write keeps the directory current afterwards with no tool call.

Commit it or ignore it, but match your store. The generated directory is derived from .context/. If your working store is gitignored (the default), gitignore .claude/rules/context-keeper/ too — otherwise a fresh clone's first constraint write regenerates from a store it doesn't have and reaps every committed file. If you share memory through export_snapshot, committing the rules directory is fine and gives a new teammate the scoped rules immediately.

v0.15: Two-Way Mirror (local <-> remote)

Optional, fail-soft mirroring so a second device — e.g. a phone recording decisions on the go — can both receive the desktop's memory and contribute its own. The local .context/ JSON store stays canonical; the context-keeper-remote Cloudflare Worker is a sync surface, never the source of truth. (Distinct from the git-committed export_snapshot mechanism — the mirror is live and cross-device, the snapshot is a versioned bundle in the repo.)

Conflict resolution is last-writer-wins by updated_at timestamp, applied identically in both directions, so an edit made on either device converges everywhere. Neither side ever deletes — a deprecation is a status change that propagates like any other edit.

The clock-skew caveat, stated honestly. "Last writer" is decided by comparing ISO-8601 updated_at strings, and those timestamps come from whichever machine did the write — the desktop's wall clock for a local edit, the Worker's for a remote one. Two edits to the same entry made within the clock skew between those machines (realistically sub-second, cross-device) can therefore misorder: the copy stamped later isn't guaranteed to be the one written later, so the guard can pick the wrong winner. There is no logical clock or vector clock to break the tie — just wall time. The blast radius is bounded (a single pull compares only the remote's own timestamps, against one clock, so intra-remote ordering is exact; skew only bites when a local write races a remote one on the same id), and it is never silent: whenever newest-wins overwrites a copy that differed in substance, the losing version is appended to .context/.mirror_conflicts.json (see below) so you can reconcile by hand.

  • Mirror out (local -> remote). After every write (record_entry/record_*, update_entry, deprecate_entry) the entry is pushed via the remote's upsert_entries MCP tool (one call per kind). upsert_entries preserves the incoming id and replaces an existing remote copy only when the pushed entry's updated_at is newer — so an edit or deprecation actually overwrites the stale remote copy instead of being skipped. If the remote is unreachable the entry is queued to .context/.mirror_queue.json (deduped to its latest state) and flushed on the next successful push. A push failure never blocks or fails the local write.
  • Mirror in (remote -> local). pull_remote calls the remote's query_entries and merges every returned row by timestamp — a remote entry whose id exists locally overwrites the local copy only when the remote's updated_at is newer; a newer local copy is kept and pushes back on its next write. The .context/.mirror_watermark is only a bookkeeping hint now: every fetched row goes through the per-entry newest-wins merge, so a remote entry is never dropped merely because its timestamp falls at or below the watermark (an earlier build filtered on the watermark before merging, which could silently lose a phone-recorded entry under cross-device clock skew — the exact scenario the mirror exists for). Wired into the SessionStart hook (so desktop sessions start with phone-recorded entries present) and exposed through the mirror MCP tool as op="pull".
  • Conflicts are preserved, not lost. When either direction overwrites a copy that differed in substance (not just timestamps), the losing version is appended to .context/.mirror_conflicts.json. Last-writer-wins has already resolved which copy is live; this is the audit trail of what it replaced. No resolution UI — the record is there if you need to reconcile by hand.
  • Backfill (mirror with op="backfill") pushes the entire local store to the remote (one upsert_entries call per kind) — for seeding a fresh remote. Idempotent: an equal-or-older re-push is skipped server-side.
  • Collision-safe IDs (Option B: random suffix). Two stores minting sequential ids independently would collide — the desktop and the Worker both hand out dec-013 for different decisions, and an upsert keyed by (project, id) would then let one silently overwrite the other. Fix: when mirroring is enabled, new ids get a short random hex suffix — dec-013-a7f3. The number still leads (sortable, greppable); old ids are never rewritten; and with mirroring off ids stay bare dec-013 (single writer, no collision possible).
  • Zero new dependencies (stdlib urllib only), no secrets in code: the remote URL contains the auth token as its final path segment (/mcp/<token>) and comes from an env var only.

Enable by setting one env var where the MCP server runs:

CONTEXT_KEEPER_REMOTE_URL=https://context-keeper-remote.<acct>.workers.dev/mcp/<AUTH_TOKEN>
# CONTEXT_KEEPER_REMOTE_TIMEOUT=5    # optional per-request seconds

With no CONTEXT_KEEPER_REMOTE_URL set, every mirror path is a silent no-op — behavior is identical to pre-v0.15.

Transport: stateless JSON-RPC over Streamable HTTP. Each write is one POST to the /mcp/<token> URL (tools/call → upsert_entries); the pull op (mirror with op="pull") calls the remote's query_entries. No initialize/session handshake (the server is stateless); the response is a single application/json body.

v0.14: Dedup Merge (deprecate_entry(merge_into=...))

Capture-time detection already caught near-duplicates (similar_entries with a likely_restatement relation), but resolving one was a manual two-step: deprecate the duplicate, then update_entry the original to fold in anything it was missing. v0.14 collapses that into one atomic, non-destructive operation.

  • Opt-in param on the existing tool, not a new tool. deprecate_entry(id=<dupe>, reason=..., merge_into=<survivor>) folds the duplicate's unique content into the survivor, then deprecates the duplicate with superseded_by=<survivor>. When merge_into is absent, deprecate_entry behaves exactly as before — byte-for-byte.
  • Additive and non-destructive. The survivor can only gain content: list fields (tags, retrieval_hints, related_to, constraints/constraints_created) are unioned, and empty text fields are backfilled from the duplicate — a non-empty field on the survivor is never overwritten. The duplicate isn't hard-deleted; it stays on disk as a deprecated entry pointing at the survivor, so the merge is fully auditable and reversible.
  • Same-type, single-write, validated first. Merge requires both entries to be the same type (so their schemas line up), resolves and validates the target before any write, and mutates both entries in one file write so the two updates can't clobber each other. A bad merge_into (missing target, cross-type, or self) errors cleanly and deprecates nothing.
  • Roots held. Explicit and agent-invoked (like every other lifecycle tool), zero new dependencies, no LLM call, deterministic. It streamlines the restatement workflow the capture loop already prescribes rather than adding a background process.
// dec-002 restates dec-001 — merge and retire it in one call
{ "id": "dec-002", "reason": "Restatement of dec-001", "merge_into": "dec-001" }
// -> dec-001 gains dec-002's unique tags/hints/related_to + any text it lacked;
//    dec-002 becomes deprecated with superseded_by = dec-001

v0.13: Structured Field Query (query_entries)

get_context answers "what's relevant to what I'm working on?" — it ranks by relevance, blends optional semantics, and flags low-relevance results with an abstention signal. That's the right tool for fuzzy recall, but the wrong one when you already know the exact field values you want. query_entries fills that gap: deterministic filtering over the structured fields that already exist on every entry, no ranking and no abstention.

  • Exact predicates, AND-combined: types, status (active/superseded/deprecated), origin (user/agent/import), tags_any, tags_all, scope (exact, case-sensitive), hardness (absolute/advisory), supersedes / superseded_by, and the same since / before temporal filters as get_context. Every predicate is a hard match over an existing field — a query either matches or it doesn't.
  • No relevance, no confabulation. Results come back in stable natural-ID order with no score and no min_relevance floor — an empty result set is a real, honest answer, not an abstention message. The abstention machinery is for fuzzy text queries; a structured predicate doesn't need it.
  • Same store, same budget. It reuses the exact store-reading and entry-serialization paths get_context uses, and packs the matched set into the same token budget (default 4000, token_budget per call), so a broad query can't dump the store — matched_entries vs entries_returned and a budget_truncated flag tell you if the cap clipped anything.
  • Additive and self-contained. Zero new dependencies, stdlib only, no embeddings and no LLM call — pure in-memory filtering over JSON already on disk. get_context, the semantic blend, the scoring, and every hook are untouched; default behavior of every existing tool is byte-for-byte unchanged.

One deliberate difference from get_context: query_entries applies no default status filter, so superseded and deprecated entries are returned unless you pass status. get_context always hides deprecated entries; the structured tool lets you ask for them on purpose.

Examples:

// Absolute constraints scoped to the hooks/ directory
{ "types": ["constraints"], "hardness": "absolute", "scope": "hooks/" }

// User-stated decision that superseded dec-005
{ "origin": "user", "supersedes": "dec-005" }

// Active pipelines tagged "release"
{ "types": ["pipelines"], "status": "active", "tags_any": ["release"] }

// Everything a user asserted this month, across all types
{ "origin": "user", "since": "2026-07-01" }

v0.12: Contradiction Detection + Cache-Stable Injection

  • Restatement vs contradiction, at capture time. The similar-entry pass already caught heavy overlaps; now it classifies each one. Two dependency-free signals — negation asymmetry ("X is required" vs "X is not required") and antonym polarity ("always" here / "never" there, "enable" / "disable") — label a match likely_restatement or likely_contradiction. A restatement nudges you to merge; a contradiction raises a contradiction_note telling the agent to resolve which rule is current (deprecate_entry with superseded_by) instead of silently leaving two live rules that disagree. Advisory only, and only evaluated on pairs Jaccard already flagged as overlapping — the write always proceeds. Zero new dependencies, no LLM call, no added tokens at record time.
  • Cache-stable session-start injection. The injected memory block is ordered so its large stable portion — constraints, decisions, pipelines, and the fixed capture guidance — forms a prefix that repeats byte-for-byte across sessions when the store hasn't changed, while the one volatile line (the quality scan's flagged IDs) is emitted last. This keeps the memory block inside the model's cacheable prompt prefix rather than busting the cache each session. It also reduces tokens rather than adding them.

v0.11: Mid-Session Constraint Re-Injection (opt-in)

The SessionStart hook injects your constraints once, at turn one. As a long session fills with tool output, those rules scroll out of the model's working attention and effectively decay — the model can violate a constraint it was briefed on an hour ago simply because it is buried. v0.11 re-surfaces the constraints during a long session, not just at the start.

Two ways in, both constraints-only — they re-inject the exact Absolute/Advisory block SessionStart shows, and nothing else from the store (no decisions, no pipelines). It's a lightweight rules refresh, not a second full dump.

  • reload_constraints tool — returns the current constraints block on demand. Always available; call it whenever you want the rules back in context.
  • constraint_reinject.py hook (PostToolUse) — opt-in, default off. When enabled, it counts tool calls per session and re-injects the constraints block every N calls (every_n_tools, default 25) via additionalContext.

What triggers it, honestly. The automatic path is the PostToolUse hook — that surface is injected into the model, and its firing rate tracks tool-output volume, which is the thing actually burying the rules. It is not a timer: an MCP server has no wall-clock inside the context window, so re-injection is driven by counting tool calls, not elapsed seconds. It is not PreCompact either — PreCompact stdout is shown only to the user, never injected into the model (the compaction boundary is already re-covered by the SessionStart hook, which re-fires with source compact).

Default behavior is unchanged. With no config (or enabled: false), the hook is inert and SessionStart works exactly as before. Enable it in .context/config.json:

{ "constraint_reinjection": { "enabled": true, "every_n_tools": 25 } }

v0.10: Abstention + Supersession-as-Ranking

Two ideas adapted from studying Curion, kept dependency-free:

  • get_context can now say "I don't have anything relevant." Previously it always returned its top-scored entries — but the composite score banks ~55 points from recency/status/origin regardless of relevance, so a query with no relevant memory silently got a confident-looking result. Measured confabulation was 100% on no-answer queries (evals/abstention.py). Now the response carries top_relevance and, when the top entry's tag/text relevance falls below min_relevance (config, default 0.20), no_confident_match: true with guidance telling the agent not to present the entries as established fact. It annotates, never suppresses — weak matches are still returned, so the vocabulary-mismatch recall that retrieval_hints and the semantic blend preserve survives. 0.20 is the highest floor with zero false-abstention on the eval set.
  • Supersession as a ranking signal, not just a filter. record_decision accepts supersedes: [ids]: the prior decisions become superseded — demoted in ranking but still recallable ("why did we change from X?"), distinct from deprecate_entry which removes an entry from retrieval entirely. Superseded entries are skipped by prune_stale/verify_quality (they're intentional history, not stale work) and marked **SUPERSEDED** by dec-NNN in the DECISIONS.md projection.

Deliberately not adopted from Curion: its LLM-controller architecture (an API call on every store and recall). context-keeper stays zero-dependency and offline by default.

v0.9: Topic Clustering, More Embedding Backends + a Bug the Measurement Caught

  • Critical fix: empty session-start injection for large stores. The summary truncation loop evaluated the original text in its condition, so any store whose summary exceeded the token budget (~30+ entries) silently popped every line and injected an empty summary at session start. Found while measuring token reduction: a 78-entry store was injecting ~0 tokens of memory. Now truncates correctly to budget.
  • Topic clustering. Above 8 decisions, get_project_summary groups decisions by their most-frequent shared tag instead of one flat list — a 59-decision store reads as a dozen topics.
  • OpenAI-compatible embeddings. semantic.api: "openai" points the semantic blend at any /v1/embeddings endpoint — LM Studio, llama.cpp server, or OpenAI itself (api_key_env names the env var holding the key). Ollama stays the default; same fail-safe lexical fallback. nomic task prefixes now apply only to nomic models.
  • Trust-aware conflict guidance. similar_entries matches now carry each entry's origin, and the guidance states the precedence: user-stated overrides agent-inferred overrides imported.
  • Token-reduction measurement (evals/token_reduction.py), run against four real stores:
storeactive entriesfull store (tokens)injected at session startreduction
balatron78~75,277~2,05797.3%
clark55~35,445~2,10294.1%
context-keeper13~5,692~82885.5%
conductor9~1,538~41173.3%

Baseline = dumping every active entry into context; injected = the get_project_summary output the SessionStart hook prints. Honest caveat: the summary is budget-capped (default 2000 tokens), so for large stores part of the reduction is by construction — the meaningful property is that injected cost stays flat as stores grow.

  • Six more MCP clients documented (OpenCode, Copilot CLI, Antigravity, OpenClaw, Hermes, pi/oh-my-pi) — see Other MCP clients below.

v0.8: DECISIONS.md Projection (render-on-write)

Opt-in: mirror the decisions store into a human-readable DECISIONS.md at the project root. Enable in .context/config.json:

{ "markdown_export": { "enabled": true, "path": "DECISIONS.md" } }
  • Render-on-write. Every tool call that mutates a decision (record_decision, update_entry, deprecate_entry) regenerates the entire file from decisions.json after the JSON write and before the tool returns — so a subsequent git commit captures both in the same commit. Deliberately not a git/PostToolUse hook: rendering after the commit snapshot would reintroduce drift.
  • JSON stays canonical; markdown is derived and read-only. The file is regenerated whole every time — never appended to, merged, or parsed back in. Hand edits are not preserved; a regenerated projection has no drift surface.
  • export_markdown tool regenerates on demand (optionally to a custom path), so existing repos can backfill without enabling the flag.
  • Pure stdlib string formatting; default behavior with the flag off is byte-for-byte unchanged.

Born from field use: Balatron's DECISIONS.md was kept in sync with the store by hand, one mirror-edit per commit. This automates that convention.

v0.7: Anticipated Queries, Origin Trust, Timeline Filters

  • retrieval_hints (all record_* tools): 2-4 alternate phrasings a future session might search for — synonyms, symptom descriptions, error messages. Indexed for both lexical and semantic retrieval, so vocabulary-mismatch queries ("value network diverging" vs. "value head saturating") can hit without embeddings. The zero-dependency complement to the semantic blend.
  • origin + trust weighting (all record_* tools): entries record who authored them — user (explicitly stated), agent (inferred from the session), or import (backfilled). Retrieval scoring gives user-stated entries a trust boost over agent-inferred, which outrank imports. Pre-v0.7 entries score as agent, preserving their relative order.
  • since / before on get_context: temporal filters against each entry's verified/created timestamp — "what did we decide this month" is now a query.

v0.6: Capture-Time Guardrails

  • Scoped constraint injection. New scope_guard.py hook (PostToolUse on Edit|Write|NotebookEdit): the moment the agent edits a file covered by a constraint's scope, that constraint is injected into context via additionalContext. Session-start injection briefs the model once at turn one; this enforces the rule at the exact moment it's about to matter. Each constraint fires at most once per session.
  • Similar-entry surfacing at record time. record_* now compares the new entry against the store (word-set Jaccard, threshold configurable via similar_threshold) and returns similar_entries when existing entries overlap heavily — catching restatements and contradictions at capture instead of relying on MMR to mitigate duplicates at retrieval. Advisory only: the write always proceeds.

v0.5: Data Integrity + Retrieval Fixes

  • Atomic writes. Entry files are written to a temp file and swapped in with os.replace, so a crash mid-write can no longer leave a truncated JSON file behind.
  • Corrupt-store protection. If an entry file exists but can't be parsed, record_*/update_entry/deprecate_entry now refuse to write (previously a corrupt file read as empty, and the next record silently replaced your entire history with one entry). Read-only tools still degrade gracefully.
  • update_entry enforces the schema. Structured fields (why_chosen, problem, reason, purpose, ...) are min-length validated on update too, so entries can't be hollowed out after recording.
  • Better budget packing. get_context skips entries that don't fit the token budget and keeps packing smaller ones, instead of stopping at the first oversized entry.
  • Fresh compaction reports. The SessionStart hook now runs the snapshot comparison itself (SessionStart fires with source compact immediately after compaction — before any Stop), so the injected report is never one compaction stale. It also injects a one-line quality-scan nudge, which is the model-visible surface for verify_quality (PreCompact stdout is only shown to the user, not the model).
  • Semantic layer shipped in the package (semantic_index.py was missing from the wheel/sdist), with batched embedding requests and one fewer HTTP round-trip per query.

v0.4: Structured Rationale + Arc Linking

Earlier versions used a single freeform rationale field. In practice, agents wrote one-line summaries instead of full reasoning — defeating the point. v0.4 fixes this three ways:

  1. Schema-enforced depth. record_decision requires problem (min 40 chars), why_chosen (min 60 chars), and accepts optional what_we_tried and tradeoffs. record_pipeline requires purpose. record_constraint enforces reason ≥ 40 chars and accepts optional triggering_incident. Thin entries are rejected server-side with field-specific guidance — the lazy path no longer produces a useful entry.
  2. Arc linking via related_to. Every entry can reference IDs of related entries. get_context traverses these links by default (depth=1), so when you retrieve one decision the rest of its arc comes along. Connective tissue survives across sessions.
  3. Quality verification. A new verify_quality tool scans for legacy entries, thin reasoning, missing tags, and isolated entries (tag overlap with no related_to). The PreCompact hook calls it automatically and surfaces flagged entries so they can be enriched before context is compressed.

Legacy entries (pre-v0.4) stay valid — they're never auto-rejected, just flagged by verify_quality for optional enrichment. The deprecated rationale parameter still works on record_decision for backward compatibility (it auto-maps to why_chosen), but problem is still required.

Install

Two ways to install, depending on your client. Claude Desktop users get the one-click bundle; everything else uses the standard stdio server.

Option A — Claude Desktop one-click bundle (.mcpb)

Context Keeper ships as an MCPB desktop extension: a single .mcpb file you install without touching any config.

  1. Download context-keeper-<version>.mcpb from the Releases page.
  2. Double-click it (or drag it into Claude Desktop → Settings → Extensions).
  3. When prompted, choose a Storage directory — the folder where your project memory lives (a .context/ subfolder of readable JSON is created there). Then enable the extension.

That's it — no pip, no JSON editing. The bundle is stdlib-only Python, so it has no third-party dependencies to install. (Claude Desktop provides the Python runtime for .mcpb python extensions; you need Python available for it to launch.)

The bundle is built reproducibly from this repo with scripts/build-mcpb.sh, and CI attaches it to each version's GitHub Release automatically.

Option B — pip + stdio (Claude Code, Cursor, Codex, any MCP client)

pip install context-keeper-mcp
Claude Code
claude mcp add --scope user context-keeper -- python /path/to/context-keeper/server.py
Claude Desktop (manual config)

Prefer editing config by hand instead of the .mcpb bundle? Add to your claude_desktop_config.json:

{
  "mcpServers": {
    "context-keeper": {
      "command": "python",
      "args": ["/path/to/context-keeper/server.py"],
      "env": {
        "CONTEXT_KEEPER_PROJECT": "/path/to/your/project"
      }
    }
  }
}
Other MCP clients (Cursor, Codex CLI, Gemini CLI, Windsurf, ...)

The server is a standard stdio MCP server, so any MCP-capable client can use it — the hooks are Claude Code extras, not requirements. Point your client's MCP config at python /path/to/context-keeper/server.py and set CONTEXT_KEEPER_PROJECT:

Cursor (~/.cursor/mcp.json or per-project .cursor/mcp.json) and Windsurf (~/.codeium/windsurf/mcp_config.json) use the same shape as Claude Desktop:

{
  "mcpServers": {
    "context-keeper": {
      "command": "python",
      "args": ["/path/to/context-keeper/server.py"],
      "env": { "CONTEXT_KEEPER_PROJECT": "/path/to/your/project" }
    }
  }
}

OpenAI Codex CLI (~/.codex/config.toml):

[mcp_servers.context-keeper]
command = "python"
args = ["/path/to/context-keeper/server.py"]
env = { "CONTEXT_KEEPER_PROJECT" = "/path/to/your/project" }

Gemini CLI (~/.gemini/settings.json) uses the same mcpServers JSON shape as Cursor above.

GitHub Copilot CLI (~/.copilot/mcp-config.json) and oh-my-pi (mcpServers config) use the mcpServers shape with "type": "stdio":

{
  "mcpServers": {
    "context-keeper": {
      "type": "stdio",
      "command": "python",
      "args": ["/path/to/context-keeper/server.py"],
      "env": { "CONTEXT_KEEPER_PROJECT": "/path/to/your/project" }
    }
  }
}

OpenCode (opencode.json):

{
  "mcp": {
    "context-keeper": {
      "type": "local",
      "command": ["python", "/path/to/context-keeper/server.py"],
      "environment": { "CONTEXT_KEEPER_PROJECT": "/path/to/your/project" }
    }
  }
}

Antigravity (~/.gemini/config/mcp_config.json or workspace .agents/mcp_config.json) and OpenClaw (openclaw.json) use the mcpServers sh

Installation

Source-derived launch command. Check the maintainer’s required arguments and credentials before running:

bash
uvx context-keeper-mcp

Set up in your AI client

Merge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.

json
{
  "mcpServers": {
    "io-github-jarmstrong158-context-keeper": {
      "command": "uvx",
      "args": [
        "context-keeper-mcp"
      ]
    }
  }
}

Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.

Claude Desktop setup reference

Package

context-keeper-mcppypi

Compatible MCP Clients

io.github.jarmstrong158/context-keeper works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.

  • Claude Desktop~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.
  • Cursor~/.cursor/mcp.jsonRestart Cursor for changes to take effect.
  • VS Code.vscode/mcp.jsonReload VS Code window for changes to take effect.
  • Windsurf~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect.
  • Claude Code.mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.

Learn More