Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ It's a memory layer for Claude Code that (with the default sqlite backend) runs

1. **As it works**, the assistant records what it tried and how it turned out — a fix that worked, an approach that failed, a preference you stated.
2. **Between sessions**, those raw notes are distilled into short, durable lessons — signal kept, noise dropped.
3. **Next session**, lessons surface exactly when they become relevant: your standing rules inject at session start, and everything else arrives as you work — each prompt and the session's first tool call are scored against the memory store (text match + semantic similarity + track record), and only memories with real evidence of relevance inject. No firehose, no guessing.
3. **Next session**, lessons surface exactly when they become relevant: each typed prompt is scored against the memory store (distinct-keyword evidence plus track record), and every tool call is checked against each memory's triggers, so a pitfall about Bash heredocs arrives as the heredoc is about to run and a planning rule arrives when the planning skill is invoked. Session start carries only a one-line index. No firehose, no guessing.
4. **Every serve gets scored.** At session end the assistant rates each surfaced memory — and any non-trivial rating requires a one-line receipt (what the memory changed, or a quote). Hit rates drive the ranking: memories that keep helping rise, memories that keep getting ignored sink, and brand-new lessons get a reserved trial slot to earn their score.

## What you get
Expand Down Expand Up @@ -84,7 +84,7 @@ uv run better-memory setup
That single command:
- Creates `~/.better-memory/{spool,state,install-backups,knowledge-base/{standards,languages,projects}}` and a default `settings.json` if none exists.
- Registers the MCP server in `~/.claude.json` (creating the file if needed).
- Installs eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var into `~/.claude/settings.json`: `session_bootstrap` (SessionStart) opens/reuses the session's background episode and injects curated context — project-scoped and general-scope semantic memories plus distilled reflections (`do` / `dont` / `neutral` buckets) — as `additionalContext`, with only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5) rendered in full; `observer` (PostToolUse, `Write|Edit|Bash`) captures tool-call snapshots into the spool; `session_close` + `stop_sweep` (Stop) drive the end-of-session rating sweep and a reminder to record non-obvious observations; `contextual_inject` (UserPromptSubmit every prompt, and PreToolUse unscoped but latched to one firing per session) surfaces memories relevant to the current prompt/tool-input — see [Configuration](website/configuration.md) and [Architecture](website/architecture.md#injection-strategies) for how it scores, floors, and dedups candidates; `commit_checkpoint` (PreToolUse, gated to `Bash(git commit*)`) reminds Claude to record an observation before committing; `pre_compact` (PreCompact) reminds Claude to persist state before context compaction.
- Installs eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var into `~/.claude/settings.json`: `session_bootstrap` (SessionStart) opens/reuses the session's background episode and injects a one-line index of what the store holds as `additionalContext` (the legacy full dump is an opt-in via `BETTER_MEMORY_INJECT_MODE=legacy`); `observer` (PostToolUse, `Write|Edit|Bash`) captures tool-call snapshots into the spool; `session_close` + `stop_sweep` (Stop) drive the end-of-session rating sweep and a reminder to record non-obvious observations; `contextual_inject` (UserPromptSubmit every prompt, and PreToolUse every tool call) surfaces memories relevant to the current prompt through a distinct-keyword evidence gate, and memories whose triggers match the current tool call — see [Configuration](website/configuration.md) and [Architecture](website/architecture.md#injection-strategies) for how it scores, floors, and dedups candidates; `commit_checkpoint` (PreToolUse, gated to `Bash(git commit*)`) reminds Claude to record an observation before committing; `pre_compact` (PreCompact) reminds Claude to persist state before context compaction.
- Splices the managed CLAUDE.md block (the retrieve/reinforce/synthesize/record protocol) into `~/.claude/CLAUDE.md` between `<!-- BEGIN better-memory (managed) -->` / `<!-- END -->` markers, leaving the rest of your CLAUDE.md untouched.
- Symlinks (or, without Windows symlink privilege, directory-junctions) every Claude Code skill found in the repo's `.claude/skills/`, and prunes links whose repo skill was removed — see [Claude Code skills](#claude-code-skills) below.

Expand Down Expand Up @@ -118,10 +118,11 @@ One env var roots the runtime filesystem layout:
| `BETTER_MEMORY_STORAGE_BACKEND` | unset | `sqlite` or `agentcore`. Explicit override of the storage backend; when unset, `$BETTER_MEMORY_HOME/settings.json` (written by `better-memory agentcore init`) decides, falling back to `sqlite`. The env var always wins over settings.json. |
| `BETTER_MEMORY_AUTO_PRUNE` | (unset = `false`) | When set to `1`, the auto-retention runner (which fires on `memory.retrieve`, throttled to once per 24h) ALSO hard-deletes archived observations older than 365 days. **Irreversible.** Default is archive-only (status flip, reversible). Opt in only if you actively want disk space reclaimed. |
| `BETTER_MEMORY_PROJECT` | unset | Force the project name for all calls in this process. Highest-priority project-resolution signal — overrides both the `.better-memory` file and the git-derived name. Designed for subprocess scoping (e.g. ralph's executor sets it per-iteration so subagent observations land in the PBI's target_repo regardless of the worktree's cwd). Empty/whitespace-only values are treated as unset. |
| `BETTER_MEMORY_INJECT_MODE` | `legacy` (config default; `deferred` is the recommended and currently-deployed setting) | `deferred`: SessionStart injects only general-scope standing rules + a one-line index; everything else surfaces via the contextual channel as it becomes relevant. `legacy`: the original full bootstrap dump. Unknown values coerce to `legacy`. |
| `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt`, `pretool`, `both` (default), or `off`. PreToolUse fires once per session (any tool), latched. |
| `BETTER_MEMORY_INJECT_MODE` | `deferred` | `deferred` (default): SessionStart injects a one-line index only; memories surface via the typed-prompt and tool-call trigger channels as they become relevant. `legacy`: the original full bootstrap dump. Unknown values coerce to `deferred`. |
| `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt` (typed-prompt channel), `pretool` (tool-call trigger channel), `both` (default), or `off`. |
| `BETTER_MEMORY_BOOTSTRAP_TOP_N` | `5` | Legacy-mode only: project-scoped items the SessionStart bootstrap renders in full; the rest collapse into a one-line index. `0` = full dump. |
| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | **Deprecated, unused** — superseded by the evidence gate (BM25 match, or a keyword-hit fallback when unavailable). Kept for back-compat only. |
| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | Distinct non-ubiquitous prompt tokens a memory must contain to be injected by the typed-prompt channel. |
| `BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS` | `8000` | Write content length above which the `write:large` trigger fires. |
| `BETTER_MEMORY_CONTEXT_MAX_ITEMS` | `3` | Max memories the contextual-injection hook injects per firing. |
| `BETTER_MEMORY_CONTEXT_REINJECT_TURNS` | `0` | Turns before a contextually-injected memory can be re-injected. `0` = never re-inject. A turn is one firing of the `contextual_inject` hook (each user prompt, plus each matched tool call in mode `both`), not one user prompt-response cycle. |

Expand Down Expand Up @@ -298,7 +299,7 @@ See `docs/superpowers/specs/2026-04-06-better-memory-design.md` for the original
Three shipped upgrades sit on top of that foundation (specs: `2026-07-23-retrieval-quality-design.md`, `2026-07-23-deferred-injection-design.md`):

- **Wilson-score ranking.** Reflections and semantic memories rank by the lower bound of their observed hit rate — `(useful + overlooked) / (useful + overlooked + ignored)` — so a memory that helps 3 times out of 4 serves outranks one that helped 67 times out of 192. No raw-count rich-get-richer. One shortlist slot per bucket is reserved for memories with fewer than 3 ratings (the **exploration slot**), so new lessons get served, rated, and scored instead of starving; those serves are tagged (`via_exploration`) and excluded from headline usefulness metrics.
- **Deferred, evidence-gated injection.** SessionStart injects only your standing rules; each prompt (and the session's first tool call) is then scored against the store against an evidence gate — a BM25 text match for reflections, a keyword-hit fallback for semantic memories (which have no FTS substrate of their own) — with the Wilson prior fused in for ranking only. A memory injects solely on positive relevance evidence; popularity can never force an irrelevant injection.
- **Just-in-time, evidence-gated injection.** SessionStart injects an index line only. Each typed prompt is scored against the store through a distinct-hit gate (at least two distinct prompt tokens that are not ubiquitous across the pool must appear in a memory, the same rule for reflections and semantic memories) with the Wilson prior fused in for ranking only. Each tool call is checked against every memory's triggers (`tool:`, `skill:`, `bash:`, `path:`, `write:large`), so a lesson arrives at the moment it applies. Peer-agent messages and command output never count as prompts. The Diagnostics page reports the useful rate per channel so the effect is measurable.
- **Evidence-anchored ratings.** Non-`ignored` ratings require a one-line receipt, enforced server-side and stored on the exposure row; the management UI shows each memory's rating-evidence history. Ratings are the fuel for everything above, so their variance is the system's noise floor — the receipts anchor them to observable events.

## Observation lifecycle
Expand Down
23 changes: 13 additions & 10 deletions better_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,12 @@ class Config:
context_max_items: int
context_reinject_turns: int
inject_mode: Literal["deferred", "legacy"]
# NOTE: context_min_hits is DEPRECATED. The evidence-gated scorer in
# services/relevant.py (BM25 qualifiers, with a keyword-hit fallback
# when no FTS substrate is available -- or the backend's
# relevance_ranks in agentcore mode) replaced the old pure
# keyword-hits floor; contextual_inject.py no longer reads this
# field. Kept for back-compat with any external
# BETTER_MEMORY_CONTEXT_MIN_HITS overrides that still resolve here.
# Size above which a Write tool call counts as "large" for the
# ``write:large`` trigger (services/triggers.py).
trigger_large_write_chars: int = 8000
# context_min_hits is the distinct-hit floor of the prompt gate in
# services/relevant.py: a memory qualifies for contextual injection when
# at least this many distinct, non-ubiquitous query tokens appear in it.


_DEFAULT_CONTEXT_INJECT_MODE = "both"
Expand All @@ -250,9 +249,10 @@ def _resolve_context_inject_mode() -> Literal["userprompt", "pretool", "both", "


def _resolve_inject_mode() -> Literal["deferred", "legacy"]:
raw = (os.environ.get("BETTER_MEMORY_INJECT_MODE") or "legacy").strip().lower()
# Fail-safe: anything unrecognised means today's behaviour.
return "deferred" if raw == "deferred" else "legacy"
raw = (os.environ.get("BETTER_MEMORY_INJECT_MODE") or "deferred").strip().lower()
# Deferred is the default (and what `better-memory setup` installs);
# legacy is an explicit opt-in to the pre-deferred session-start dump.
return "legacy" if raw == "legacy" else "deferred"


def _read_settings_storage_backend(home: Path) -> str | None:
Expand Down Expand Up @@ -348,4 +348,7 @@ def get_config() -> Config:
context_max_items=_resolve_nonneg_int("BETTER_MEMORY_CONTEXT_MAX_ITEMS", 3),
context_reinject_turns=_resolve_nonneg_int("BETTER_MEMORY_CONTEXT_REINJECT_TURNS", 0),
inject_mode=_resolve_inject_mode(),
trigger_large_write_chars=_resolve_nonneg_int(
"BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS", 8000
),
)
14 changes: 14 additions & 0 deletions better_memory/db/migrations/0019_memory_triggers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Migration 0019: tool-call triggers on memories; trigger-channel diagnostics.
--
-- ``triggers`` holds a JSON array of trigger strings (see
-- better_memory/services/triggers.py for the grammar) or NULL when a memory
-- has none. Rows without triggers are never served by the PreToolUse
-- trigger channel. See
-- docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md §2.

ALTER TABLE reflections ADD COLUMN triggers TEXT;
ALTER TABLE semantic_memories ADD COLUMN triggers TEXT;

INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_suppressed_nonhuman', 0);
INSERT INTO rating_diagnostics (metric, value) VALUES ('trigger_fired', 0);
INSERT INTO rating_diagnostics (metric, value) VALUES ('trigger_injected', 0);
37 changes: 37 additions & 0 deletions better_memory/db/migrations/0020_exposure_source_trigger.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- Migration 0020: widen session_memory_exposure.source CHECK to include
-- 'trigger' (the PreToolUse trigger channel; see
-- docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md §1).
-- SQLite cannot ALTER a CHECK constraint, so the table is recreated with
-- every column added since migration 0012 (via_exploration, evidence,
-- display). No table holds a foreign key into session_memory_exposure.

CREATE TABLE session_memory_exposure_new (
session_id TEXT NOT NULL,
memory_kind TEXT NOT NULL CHECK(memory_kind IN ('reflection', 'semantic')),
memory_id TEXT NOT NULL,
exposed_at TEXT NOT NULL,
source TEXT NOT NULL CHECK(source IN ('bootstrap', 'retrieve', 'contextual', 'trigger')),
rated_at TEXT,
classification TEXT CHECK(classification IN
('cited', 'shaped', 'ignored', 'misled', 'overlooked')),
via_exploration INTEGER NOT NULL DEFAULT 0,
evidence TEXT,
display TEXT,
PRIMARY KEY (session_id, memory_kind, memory_id, exposed_at)
);

INSERT INTO session_memory_exposure_new
(session_id, memory_kind, memory_id, exposed_at, source, rated_at,
classification, via_exploration, evidence, display)
SELECT
session_id, memory_kind, memory_id, exposed_at, source, rated_at,
classification, via_exploration, evidence, display
FROM session_memory_exposure;

DROP TABLE session_memory_exposure;
ALTER TABLE session_memory_exposure_new RENAME TO session_memory_exposure;

CREATE INDEX idx_sme_session_unrated
ON session_memory_exposure(session_id) WHERE rated_at IS NULL;
CREATE INDEX idx_sme_memory
ON session_memory_exposure(memory_kind, memory_id);
Loading
Loading