From 6435f9fdac3a359e4a2a5b2b6020363045442660 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:16:16 +0100 Subject: [PATCH 01/11] docs: contextual memory injection hook design spec --- ...contextual-memory-injection-hook-design.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md diff --git a/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md b/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md new file mode 100644 index 0000000..858743e --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md @@ -0,0 +1,209 @@ +# Contextual Memory Injection Hook — Design + +**Date:** 2026-06-14 +**Status:** Approved design (brainstorming) +**Repo:** better-memory + +## Problem + +Memories are loaded **once**, at `SessionStart` (the bootstrap hook), surfaced as +`additionalContext` the assistant treats as background. By the time a relevant +moment arrives (often many turns later — e.g. invoking `writing-plans`), the +pertinent reflection has scrolled out of attention or been dropped by +compaction. Result: high-value memories (e.g. the 0.9-confidence +"surface planning guardrails + confidence scores" reflection) are silently +ignored unless the user explicitly asks for them. This was observed repeatedly, +including the session that motivated this design. + +Root cause: retrieval is **one-shot and filter-based**, not **continuous and +relevance-ranked to the current input**. Nothing re-surfaces the *right* memory +at the *right* moment. + +## Goal + +Surface the **relevant curated memories at the moment they matter** by matching +them against the current prompt / tool-input on every turn (or tool call), +injecting a small, relevance-filtered set as `additionalContext`. + +**In scope:** the *managed, scored, curated* memories only — +- **semantic memories** (user-stated facts/preferences), and +- **reflections** (distilled lessons), +both **project- and general-scoped**. + +**Out of scope:** observations and knowledge-base docs (raw / separately +surfaced). Embeddings/vector search. LLM-based relevance judging. + +## Non-negotiable constraints + +1. **No new external dependency.** No Ollama, no AWS semantic search, no new + pip packages. (On `main`, Ollama is the only embeddings backend and is the + default; the no-Ollama FTS embeddings backend is unmerged. We must not depend + on it.) +2. **Backend-agnostic.** Must work under both `BETTER_MEMORY_STORAGE_BACKEND` + = `sqlite` and `agentcore`. Therefore retrieval MUST go through the + `StorageBackend` abstraction (`backend.retrieve()`, `backend.semantic_list()`) + — never direct SQL or direct service calls (those are sqlite-only and are not + wired for agentcore). +3. **Fast — it blocks the turn.** `UserPromptSubmit` / `PreToolUse` injection is + synchronous. Target well under ~300 ms. This is achievable because the + curated set is *small* (tens of items per project), so we fetch the managed + ranked set and filter it in pure Python — no index, no model. +4. **Never break a turn.** The hook swallows all errors, logs to `hook_errors`, + and always exits 0. + +## Architecture + +Two pieces: a reusable retrieval method (the brains) and a thin hook (the +plumbing). Keeping the logic in a service means the MCP layer and the hook share +one implementation. + +### 1. `retrieve_relevant` — new service method (the brains) + +Location: a service callable by both the MCP server and the hook (e.g. +`better_memory/services/relevant.py`, or a method on an existing service), +constructed over the active `StorageBackend`. + +``` +retrieve_relevant( + query: str, + *, + project: str, + limit: int = 5, + kinds: tuple[str, ...] = ("reflection", "semantic"), +) -> list[RelevantMemory] +``` + +Algorithm: +1. **Fetch the managed, ranked candidate sets** via the storage abstraction + (both already union project + general, both backends implement them): + - reflections: `backend.retrieve(project=…, track_exposure=False)` → the + `do`/`dont`/`neutral` buckets (already ranked by useful_count/confidence; + `retrieve()`'s WHERE already includes `scope = 'general'`). Flatten the + buckets in order **do → dont → neutral**, preserving each bucket's internal + order; this flattened position is the "managed score" used for tie-breaking. + (`neutral` may be dropped from injection — tunable.) + - semantic: `backend.semantic_list(project=…)` → project + general + (ranked by useful_count + overlooked weight). +2. **Extract keywords from `query`**: lowercase; split on non-alphanumeric; + drop a small stopword set and tokens shorter than 3 chars. (Deterministic, + pure Python.) +3. **Whole-word filter**: keep a memory if its searchable text contains at least + one keyword as a **whole word** (regex `\bword\b`, case-insensitive). + Searchable text: + - reflection → `title` + `use_cases` + `hints` (joined), + - semantic → `content`. +4. **Order** matches by **(# distinct keyword hits) desc, then managed score + desc** (managed score = the order the backend already returned them in). +5. **Cap** to `limit` and return as a small typed result (kind, id, title/ + content, confidence/score, matched-keyword count). + +Properties: pure-Python filter over a small in-memory list → no FTS index, no +migration, no embeddings; identical behavior on sqlite and agentcore because it +operates on the dicts the abstraction returns. + +**Upgrade path (documented, not built):** if a project's curated set ever grows +large enough that fetch-all is slow, replace step 1+3 internals with an FTS5 +index over reflections/semantic *behind this same signature* — callers +unchanged. + +### 2. The hook (the plumbing) + +A thin script (`better_memory/hooks/contextual_inject.py`, dispatchable for both +events). Per the existing hook pattern (`session_bootstrap.py`): +1. Read JSON payload from stdin. +2. Derive the **query**: + - `UserPromptSubmit` → the user prompt text. + - `PreToolUse` → the tool name + args (e.g. skill name + args, or Write + target/preview), concatenated. +3. Resolve `project` (existing `config.project_name(cwd)` logic) and open the + active backend via the factory. +4. Call `retrieve_relevant(query, project=…, limit=…)`. +5. Format and print `additionalContext`. Never raise; exit 0. + +### 3. Trigger config switch + +`BETTER_MEMORY_CONTEXT_INJECT_MODE` ∈ `userprompt` | `pretool` | `both` +(default `both`). Both hooks are registered in `settings.json`; the switch makes +each hook a no-op when its event isn't selected — so the mode can be changed +without editing `settings.json`, enabling live A/B of which trigger is more +useful. + +`PreToolUse` matcher: scope to high-value tools (e.g. `Skill`, `Task`, `Write`) +to avoid firing on every trivial tool call. (Exact matcher set is a tunable.) + +### 4. Injection format + +Compact, imperative framing so it reads as guidance, not background: + +``` +RELEVANT MEMORY — apply unless it conflicts with the user's request: +• [reflection · conf 0.90] writing-plans: retrieve planning memories + surface guardrails/confidence BEFORE drafting +• [semantic] Never ask the user to babysit a PR — start the bot-watch loop automatically +``` + +Caps: **≤ 5 items**, **~400 tokens** total (truncate lowest-ranked first). + +## Decisions (from brainstorming) + +| Decision | Choice | +|----------|--------| +| Sources | semantic + reflections only (project + general) | +| Match | whole-word, case-insensitive keyword | +| Ranking | # distinct keyword hits, then managed score | +| Trigger | `UserPromptSubmit` + `PreToolUse`, with `mode` switch (userprompt/pretool/both) | +| Dedup | **None** — proximity/recency matters; a relevant memory re-injects whenever it matches *(deferred option, see below)* | +| Empty match | inject nothing | +| Exposure tracking | `track_exposure=False` for the hook (automated injection must not flood the scoring/decay counters; deliberate MCP consults still track) | +| Deps | none (no Ollama, no FTS, no new packages) | +| Backends | via `StorageBackend` abstraction; works sqlite + agentcore | + +## Deferred options (intentionally not built) + +- **Dedup / cooldown window.** Considered and rejected for v1: re-surfacing a + relevant memory at the moment it's relevant is a feature; suppressing it to + avoid repetition risks the memory being absent exactly when needed. Risk + acknowledged: repeated identical injection consumes context and, over many + turns, the assistant habituates and under-weights it. If context bloat or + habituation becomes a real problem, add a session-scoped dedup keyed by + `session_id` (suppress re-injecting a memory ID within N turns, and dedup + against the SessionStart bootstrap set). +- **Always-on floor** on empty match (top-N by managed score) — left as a toggle. +- **FTS5 relevance index** for large curated sets (see Upgrade path). +- **Blocking gate** for non-negotiables (e.g. block writing a plan until + confidence scores exist) — a separate `PreToolUse` deny hook, complementary to + this injection hook. Enforcement is a gate's job, not repetition's. + +## Error handling + +- Hook never raises: wrap body in try/except, log to `hook_errors`, emit empty + `additionalContext`, exit 0. +- `retrieve_relevant` returns `[]` on any backend error rather than throwing into + the hook. +- agentcore semantic path: if `semantic_list` is not yet implemented for + agentcore, `retrieve_relevant` degrades to reflections-only rather than + failing. (Verify current agentcore `semantic_list` status during planning.) + +## Testing + +Unit tests for `retrieve_relevant` (the logic that matters), against **both** +backends via the abstraction: +- keyword extraction (lowercasing, stopword/short-token removal), +- whole-word matching (`art` does NOT match `start`; `plan` matches `Plan.`), +- scope union (project + general both returned), +- ordering (more keyword hits ranks higher; ties broken by managed score), +- `limit` cap, +- empty-match returns `[]`, +- error path returns `[]` (no throw). + +Hook-level: feed sample `UserPromptSubmit` / `PreToolUse` payloads on stdin, +assert well-formed `additionalContext` JSON and exit 0; assert a forced internal +error still exits 0 with empty context. + +## Files (anticipated) + +- Create: `better_memory/services/relevant.py` (or method on existing service) +- Create: `better_memory/hooks/contextual_inject.py` +- Modify: `better_memory/config.py` (read `BETTER_MEMORY_CONTEXT_INJECT_MODE`) +- Modify: MCP server (optional) — expose `retrieve_relevant` as a tool too +- Modify: `scripts/setup.sh` + README/hooks-setup.md — register the new hooks +- Tests under the repo's test tree From 12d5dee16920d759b777964e28d06cf24a2ed742 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:29:21 +0100 Subject: [PATCH 02/11] docs: add assumptions + hook-framing/PreToolUse verification findings to contextual-memory spec --- ...contextual-memory-injection-hook-design.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md b/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md index 858743e..6994b1e 100644 --- a/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md +++ b/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md @@ -157,6 +157,47 @@ Caps: **≤ 5 items**, **~400 tokens** total (truncate lowest-ranked first). | Deps | none (no Ollama, no FTS, no new packages) | | Backends | via `StorageBackend` abstraction; works sqlite + agentcore | +## Assumptions & verification (2026-06-14) + +3-bucket review of design assumptions. The two load-bearing ones were verified +against the official Claude Code hooks docs before proceeding. + +**🔴 Real concerns** +1. **Lexical match may miss the most relevant memory** (the motivating case: + prompt vocabulary ≠ reflection body). *Accepted for v1* — plan/reflection + text overlaps in practice ("write the plan" ↔ planning reflections), and the + `PreToolUse`-on-`Skill` query (skill name) matches reflection `use_cases`. + Upgrade to `trigger_tags` or semantic ranking later if recall is poor. +2. **Does injected `additionalContext` escape "background" framing?** + *Verified.* `UserPromptSubmit` `additionalContext` is "injected as a system + reminder that Claude reads as plain text" (CC hooks-guide). It is NOT a + stronger instruction envelope — the gain is **per-turn relevance + recency**, + not framing weight. Observationally it IS acted upon (the caveman + `UserPromptSubmit` hook stays active across a session). Implication: the + habituation risk on repetition is real → see the deferred dedup option. +3. **`PreToolUse` for the `Skill` tool.** *Partially verified.* `PreToolUse` + supports `additionalContext` injection AND gating; matcher is the tool name + with alternation (`Skill|Task|Write`); the hook reads `tool_name`/`tool_input` + from stdin. **Gap:** docs don't confirm `PreToolUse` fires for the built-in + `Skill`/`Task` tools (examples are Bash/Edit/Write/mcp__). **Planning task: + empirically confirm Skill firing**; if it doesn't fire, `UserPromptSubmit` + remains the reliable backbone and the planning case is still covered by prompt + text. Not a blocker. + +**🟠 Medium** +4. **Per-turn cold-start latency** (~150–250 ms python start on the turn's + critical path). Mitigate with lean imports; revisit a persistent helper. +5. **`track_exposure=False` discards the surfaced-but-ignored signal** + (`times_overlooked`). Trade-off; revisit if ranking needs that feedback. + +**🟢 Verified-safe:** reflections have general scope (migration 0007); +abstraction exposes `retrieve()` + `semantic_list()`; whole-word match needs no +deps; hook stdin/exit-0 pattern proven. + +**⚪ Minor / accepted (tunables):** `neutral` bucket inclusion, `PreToolUse` +matcher set, injection cap (5 / ~400 tok), keyword min-length 3 (drops 2-char +tokens like "PR"/"CI"), no-dedup context growth. + ## Deferred options (intentionally not built) - **Dedup / cooldown window.** Considered and rejected for v1: re-surfacing a From fd95f33a69f36bd1c8163c74e668a53d66146d8e Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:32:00 +0100 Subject: [PATCH 03/11] =?UTF-8?q?docs:=20re-bucket=20assumptions=20by=20st?= =?UTF-8?q?atus=20=E2=80=94=20no=20open=20blockers=20after=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...contextual-memory-injection-hook-design.md | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md b/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md index 6994b1e..2760cc1 100644 --- a/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md +++ b/docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md @@ -159,36 +159,37 @@ Caps: **≤ 5 items**, **~400 tokens** total (truncate lowest-ranked first). ## Assumptions & verification (2026-06-14) -3-bucket review of design assumptions. The two load-bearing ones were verified -against the official Claude Code hooks docs before proceeding. - -**🔴 Real concerns** -1. **Lexical match may miss the most relevant memory** (the motivating case: - prompt vocabulary ≠ reflection body). *Accepted for v1* — plan/reflection - text overlaps in practice ("write the plan" ↔ planning reflections), and the - `PreToolUse`-on-`Skill` query (skill name) matches reflection `use_cases`. - Upgrade to `trigger_tags` or semantic ranking later if recall is poor. -2. **Does injected `additionalContext` escape "background" framing?** - *Verified.* `UserPromptSubmit` `additionalContext` is "injected as a system - reminder that Claude reads as plain text" (CC hooks-guide). It is NOT a - stronger instruction envelope — the gain is **per-turn relevance + recency**, - not framing weight. Observationally it IS acted upon (the caveman - `UserPromptSubmit` hook stays active across a session). Implication: the - habituation risk on repetition is real → see the deferred dedup option. -3. **`PreToolUse` for the `Skill` tool.** *Partially verified.* `PreToolUse` - supports `additionalContext` injection AND gating; matcher is the tool name - with alternation (`Skill|Task|Write`); the hook reads `tool_name`/`tool_input` - from stdin. **Gap:** docs don't confirm `PreToolUse` fires for the built-in - `Skill`/`Task` tools (examples are Bash/Edit/Write/mcp__). **Planning task: - empirically confirm Skill firing**; if it doesn't fire, `UserPromptSubmit` - remains the reliable backbone and the planning case is still covered by prompt - text. Not a blocker. - -**🟠 Medium** -4. **Per-turn cold-start latency** (~150–250 ms python start on the turn's - critical path). Mitigate with lean imports; revisit a persistent helper. -5. **`track_exposure=False` discards the surfaced-but-ignored signal** - (`times_overlooked`). Trade-off; revisit if ranking needs that feedback. +Status after verifying the two load-bearing risks against the official Claude +Code hooks docs. **No open 🔴 blockers remain.** + +**✅ Resolved by verification** +- **(was #2) `additionalContext` framing.** `UserPromptSubmit` `additionalContext` + is "injected as a system reminder that Claude reads as plain text" (CC + hooks-guide). Not a stronger instruction envelope — the gain is **per-turn + relevance + recency**, not framing weight. Confirmed acted-upon in practice + (the caveman `UserPromptSubmit` hook stays active across a session). + `PreToolUse` also supports `additionalContext` injection *and* allow/deny + gating. Premise holds. +- **(was #3) `PreToolUse` mechanism.** Matcher = tool name with alternation + (`Skill|Task|Write`); hook reads `tool_name`/`tool_input` from stdin; + `additionalContext` is passed to Claude. The ONE residual unknown — does + `PreToolUse` fire for the built-in `Skill`/`Task` tools? (docs only show + Bash/Edit/Write/mcp__) — is reduced to a **single empirical check in the plan + (Task 0)**; `UserPromptSubmit` is the reliable backbone regardless, so this is + not a blocker. + +**🟡 Accepted for v1 (decided, with upgrade path)** +- **(was #1) Lexical match may miss a relevant memory** (prompt vocabulary ≠ + reflection body). Accepted: plan/reflection text overlaps in practice ("write + the plan" ↔ planning reflections) and the `PreToolUse`-on-`Skill` query (skill + name) matches reflection `use_cases`. Upgrade to `trigger_tags` or semantic + ranking later if recall proves weak. + +**🟠 Revisit during build (not blocking)** +- **Per-turn cold-start latency** (~150–250 ms python start on the turn's + critical path). Mitigate with lean imports; revisit a persistent helper. +- **`track_exposure=False` discards the surfaced-but-ignored signal** + (`times_overlooked`). Trade-off; revisit if ranking needs that feedback. **🟢 Verified-safe:** reflections have general scope (migration 0007); abstraction exposes `retrieve()` + `semantic_list()`; whole-word match needs no From 0c5581a3cfe15edc1ce034f0190e63ea49525a74 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:39:28 +0100 Subject: [PATCH 04/11] docs: contextual memory injection hook implementation plan (guardrails + per-task confidence) --- ...-06-14-contextual-memory-injection-hook.md | 850 ++++++++++++++++++ 1 file changed, 850 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-contextual-memory-injection-hook.md diff --git a/docs/superpowers/plans/2026-06-14-contextual-memory-injection-hook.md b/docs/superpowers/plans/2026-06-14-contextual-memory-injection-hook.md new file mode 100644 index 0000000..b114559 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-contextual-memory-injection-hook.md @@ -0,0 +1,850 @@ +# Contextual Memory Injection Hook — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface the *relevant* curated memories (semantic + reflections, project + general) at the moment they matter, by keyword-matching against the current prompt / tool-input via a Claude Code hook — closing the "loaded once at startup, then ignored" gap. + +**Architecture:** A pure-Python relevance filter (`retrieve_relevant`) fetches the small, already-ranked curated sets through the `StorageBackend` abstraction (works on sqlite *and* agentcore), whole-word keyword-filters them against the query, and returns the top matches. A thin hook (`contextual_inject`) wires it to `UserPromptSubmit` and `PreToolUse`, gated by a config mode switch. No new deps, no embeddings, no schema migration. + +**Tech Stack:** Python 3.12, SQLite via existing `StorageBackend`, pytest (+pytest-asyncio), ruff (E/F/I/UP/B, line 100), pyright (standard). Spec: `docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md`. + +--- + +## Memory Guardrails + +Retrieved via `memory_retrieve` (planning + implementation) + `knowledge_list` before drafting. + +- **`keep-docs-in-sync` — conf 0.95, useful 13 (implementation).** Adding an env var, a hook, or an MCP tool MUST update README + `website/configuration.md` + `docs/hooks-setup.md` (+ `website/mcp-tools.md` and tool counts if a tool is added) **in the same task** — deferring docs to a trailing task is a code smell. Tasks 5/7/9 bake this in. +- **`apply-confidence-scoring` — knowledge standard `ralph-runtime.md` (gate).** Every task carries a confidence %; sub-90 embeds its mitigation. Test/snippets must be lint-clean for py312 ruff `UP` (`from datetime import UTC`; never `class X(str, Enum)` → use `StrEnum`). +- **`writing-plans-surface-guardrails` — conf 0.90.** This section. +- **`verify-before-commit-internal-patterns` — standard.** Service/test/hook/config/MCP patterns were read from the repo (conftest `tmp_memory_db`, `connect`+`apply_migrations`, `session_bootstrap.py` skeleton, `_resolve_*` config style, `StorageBackend.retrieve/semantic_list`, `build_registry`); signatures below match them. +- **`prioritise-root-cause` — conf 1.0.** This whole feature is the root-cause fix for "memories ignored"; keep it relevance-correct, not a patch. + +**Dismissed (considered, n/a):** Playwright textContent (0.8 — no template/Playwright work here), tempfile-fd-leak (0.6 — no temp files), freeze enter/exit logging (0.55), fail-fast-ordering doc (0.55 — no such guard here). + +--- + +## File Structure + +- **Create** `better_memory/services/keywords.py` — `extract_keywords()`, `count_keyword_hits()` (pure, no deps). +- **Create** `better_memory/services/relevant.py` — `RelevantMemory` dataclass, `retrieve_relevant(backend, …)`, `format_relevant(items)`. +- **Create** `better_memory/hooks/contextual_inject.py` — the hook entry (`UserPromptSubmit` + `PreToolUse`), mode-gated. +- **Modify** `better_memory/config.py` — `context_inject_mode` field + `_resolve_context_inject_mode()`. +- **Modify** `better_memory/mcp/handlers/` (+ `server.py`) — optional `memory.retrieve_relevant` tool (Task 8). +- **Modify** `docs/hooks-setup.md`, `README.md`, `website/configuration.md` (+ `website/mcp-tools.md` if Task 8) — registration + env var + tool docs. +- **Tests** under `tests/services/`, `tests/hooks/`, `tests/mcp/`. + +--- + +## Task 0: Spike — does `PreToolUse` fire for the `Skill` tool? + +**Confidence: 95% (spike, de-risks the one residual unknown).** Resolves the only open item from the spec's assumptions. Pure investigation; no production code. + +**Files:** none committed (temporary probe). + +- [ ] **Step 1: Add a throwaway probe hook to user settings** + +In `~/.claude/settings.json`, temporarily add: + +```json +{ "hooks": { "PreToolUse": [ { "matcher": "Skill|Task", + "hooks": [ { "type": "command", + "command": "python -c \"import sys,json,datetime; d=json.load(sys.stdin); open(r'C:/Users/gethi/pretooluse_probe.log','a').write(datetime.datetime.now().isoformat()+' '+str(d.get('tool_name'))+' '+json.dumps(d.get('tool_input'))[:200]+'\\n')\"" } ] } ] } } +``` + +- [ ] **Step 2: Trigger a skill and a subagent** + +In a Claude Code session: invoke any skill (e.g. `/help` or a Skill tool call) and dispatch one Agent/Task. Then read the log: + +Run: `cat C:/Users/gethi/pretooluse_probe.log` +Expected (to confirm): one line per Skill/Task invocation containing `Skill` / `Task` and the tool_input (skill name + args). + +- [ ] **Step 3: Record the result + clean up** + +- If lines appear → `PreToolUse` fires for `Skill`/`Task`; the `pretool` mode is fully viable (query = `tool_input`). +- If NO lines appear → `PreToolUse` does NOT fire for those tools; `pretool` mode is limited to `Write`/`Bash`/etc., and `UserPromptSubmit` is the sole planning trigger. Note this in the hook's docstring and `docs/hooks-setup.md`. +Remove the probe hook from settings and delete the log. No commit. + +--- + +## Task 1: Keyword extraction + whole-word matching + +**Confidence: 97%** — pure functions, fully unit-testable, no deps. + +**Files:** +- Create: `better_memory/services/keywords.py` +- Test: `tests/services/test_keywords.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/services/test_keywords.py +"""Tests for keyword extraction + whole-word matching.""" +from __future__ import annotations + +from better_memory.services.keywords import count_keyword_hits, extract_keywords + + +class TestExtractKeywords: + def test_lowercases_and_splits(self): + assert extract_keywords("Write the Plan") == {"write", "the_skip", "plan"} - {"the_skip"} | {"write", "plan"} + + def test_drops_stopwords_and_short_tokens(self): + kw = extract_keywords("we are on to the CI plan") + assert "plan" in kw + assert "the" not in kw and "on" not in kw and "we" not in kw + assert "ci" not in kw # 2-char tokens dropped (documented tunable) + + def test_dedupes(self): + assert extract_keywords("plan plan PLAN") == {"plan"} + + def test_empty(self): + assert extract_keywords(" ") == set() + + +class TestCountKeywordHits: + def test_whole_word_only(self): + kw = {"art", "plan"} + assert count_keyword_hits("let us start the planner", kw) == 0 # 'art' in 'start', 'plan' in 'planner' — neither whole-word + assert count_keyword_hits("the art of a plan", kw) == 2 + + def test_case_insensitive_and_punctuation(self): + assert count_keyword_hits("Finalise the Plan.", {"plan"}) == 1 + + def test_distinct_terms_counted_once_each(self): + assert count_keyword_hits("plan plan plan", {"plan"}) == 1 +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `uv run pytest tests/services/test_keywords.py -v` +Expected: FAIL (module `better_memory.services.keywords` not found). + +- [ ] **Step 3: Implement** + +```python +# better_memory/services/keywords.py +"""Keyword extraction + whole-word matching for contextual memory relevance. + +Pure, dependency-free. Used by retrieve_relevant to filter the curated memory +set against the current prompt / tool-input. +""" +from __future__ import annotations + +import re + +# Small, deliberately conservative stopword set. Tunable. +_STOPWORDS = frozenset({ + "the", "and", "for", "are", "was", "were", "you", "your", "our", "with", + "this", "that", "from", "into", "have", "has", "had", "but", "not", "can", + "will", "would", "should", "could", "lets", "let", "get", "got", "out", + "use", "using", "what", "when", "how", "why", "who", "all", "any", "its", + "his", "her", "they", "them", "then", "than", "now", "via", "per", +}) + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def extract_keywords(text: str) -> set[str]: + """Lowercase, tokenise on non-alphanumerics, drop stopwords + <3-char tokens.""" + if not text: + return set() + return { + tok + for tok in _TOKEN_RE.findall(text.lower()) + if len(tok) >= 3 and tok not in _STOPWORDS + } + + +def count_keyword_hits(text: str, keywords: set[str]) -> int: + """Number of distinct keywords that appear as a WHOLE WORD in text.""" + if not text or not keywords: + return 0 + lowered = text.lower() + hits = 0 + for kw in keywords: + if re.search(rf"\b{re.escape(kw)}\b", lowered): + hits += 1 + return hits +``` + +- [ ] **Step 4: Fix the first test's expected value** + +Replace the contrived line in `test_lowercases_and_splits` with the real expectation: + +```python + def test_lowercases_and_splits(self): + assert extract_keywords("Write the Plan") == {"write", "plan"} +``` + +- [ ] **Step 5: Run — expect pass** + +Run: `uv run pytest tests/services/test_keywords.py -v` +Expected: PASS (all). + +- [ ] **Step 6: Lint + commit** + +Run: `uv run ruff check better_memory/services/keywords.py tests/services/test_keywords.py` +Expected: no errors. +```bash +git add better_memory/services/keywords.py tests/services/test_keywords.py +git commit -m "feat(relevant): keyword extraction + whole-word matching" +``` + +--- + +## Task 2: `retrieve_relevant` — fetch, filter, rank + +**Confidence: 90%** — orchestrates verified abstraction methods (`backend.retrieve`, `backend.semantic_list`) over the keyword helpers. Mitigation for the one soft spot (semantic returns `SemanticMemory` objects vs reflections returning dicts): the code normalises both into a single `RelevantMemory` and the test seeds *both* kinds (project + general) and asserts ordering + scope union, so a shape mismatch fails loudly. + +**Files:** +- Create: `better_memory/services/relevant.py` +- Test: `tests/services/test_relevant.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/services/test_relevant.py +"""Tests for retrieve_relevant over a real sqlite StorageBackend.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from better_memory.db.connection import connect +from better_memory.db.schema import apply_migrations +from better_memory.services.relevant import RelevantMemory, retrieve_relevant +from better_memory.services.semantic import SemanticMemoryService +from better_memory.storage.sqlite import SqliteBackend + + +@pytest.fixture +def backend(tmp_memory_db: Path): + conn = connect(tmp_memory_db) + apply_migrations(conn) + # seed semantic: one project, one general, one irrelevant + sem = SemanticMemoryService(conn) + sem.create(content="Always write the implementation plan with confidence scores", + project="proj", scope="project") + sem.create(content="Never ask the user to babysit a PR", project="other", scope="general") + sem.create(content="Prefer tea over coffee", project="proj", scope="project") + try: + yield SqliteBackend(conn, project="proj") + finally: + conn.close() + + +def test_filters_to_keyword_matches(backend): + out = retrieve_relevant(backend, query="let us write the plan", project="proj", limit=5) + texts = " ".join(m.summary.lower() for m in out) + assert "plan" in texts + assert "coffee" not in texts # irrelevant memory excluded + + +def test_includes_general_scope(backend): + out = retrieve_relevant(backend, query="babysit the PR", project="proj", limit=5) + assert any("babysit" in m.summary.lower() for m in out) # general-scope semantic matched + + +def test_empty_on_no_match(backend): + assert retrieve_relevant(backend, query="xylophone zeppelin", project="proj", limit=5) == [] + + +def test_respects_limit(backend): + out = retrieve_relevant(backend, query="plan confidence babysit pr user", project="proj", limit=1) + assert len(out) == 1 + + +def test_returns_relevantmemory(backend): + out = retrieve_relevant(backend, query="plan", project="proj", limit=5) + assert all(isinstance(m, RelevantMemory) for m in out) + assert all(m.kind in ("reflection", "semantic") for m in out) +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `uv run pytest tests/services/test_relevant.py -v` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** + +```python +# better_memory/services/relevant.py +"""Relevance filter over the curated memory set (semantic + reflections). + +Fetches the small, already-ranked sets through the StorageBackend abstraction +(works on sqlite AND agentcore), whole-word keyword-filters them against a query, +and returns the top matches. Pure-Python; no embeddings, no new schema. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from better_memory.services.keywords import count_keyword_hits, extract_keywords + + +@dataclass +class RelevantMemory: + kind: str # "reflection" | "semantic" + id: str + summary: str # short display text + confidence: float | None + hits: int # distinct keyword hits (higher = more relevant) + + +def _reflection_text(r: dict[str, Any]) -> str: + parts = [str(r.get("title") or ""), str(r.get("use_cases") or "")] + hints = r.get("hints") or [] + if isinstance(hints, list): + parts.extend(str(h) for h in hints) + return " ".join(parts) + + +def retrieve_relevant( + backend: Any, + *, + query: str, + project: str, + limit: int = 5, + include_neutral: bool = False, +) -> list[RelevantMemory]: + """Return up to `limit` curated memories whose text whole-word-matches a + keyword from `query`, ordered by (# hits desc, managed rank asc). + + Never raises: any backend error yields an empty list (the hook must not break + a turn). `managed rank` = the order the backend already returned items in + (confidence/useful-count), flattened reflections do->dont->[neutral] then + semantic. + """ + keywords = extract_keywords(query) + if not keywords: + return [] + + candidates: list[tuple[int, RelevantMemory]] = [] # (managed_rank, mem) + rank = 0 + try: + buckets = backend.retrieve(project=project, track_exposure=False) + except Exception: + buckets = {} + order = ["do", "dont"] + (["neutral"] if include_neutral else []) + for bucket in order: + for r in buckets.get(bucket, []) or []: + text = _reflection_text(r) + hits = count_keyword_hits(text, keywords) + if hits: + candidates.append((rank, RelevantMemory( + kind="reflection", id=str(r.get("id")), + summary=str(r.get("title") or text)[:160], + confidence=r.get("confidence"), hits=hits))) + rank += 1 + + try: + semantic = backend.semantic_list(project=project, track_exposure=False) + except Exception: + semantic = [] + for s in semantic or []: + content = getattr(s, "content", "") or "" + hits = count_keyword_hits(content, keywords) + if hits: + candidates.append((rank, RelevantMemory( + kind="semantic", id=str(getattr(s, "id", "")), + summary=content[:160], confidence=None, hits=hits))) + rank += 1 + + candidates.sort(key=lambda t: (-t[1].hits, t[0])) + return [m for _, m in candidates[:limit]] + + +def format_relevant(items: list[RelevantMemory]) -> str: + """Render the additionalContext block. Empty string if no items.""" + if not items: + return "" + lines = ["RELEVANT MEMORY — apply unless it conflicts with the user's request:"] + for m in items: + tag = f"{m.kind}" + (f" · conf {m.confidence:.2f}" if m.confidence is not None else "") + lines.append(f"• [{tag}] {m.summary}") + return "\n".join(lines) +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `uv run pytest tests/services/test_relevant.py -v` +Expected: PASS. + +- [ ] **Step 5: Lint/typecheck + commit** + +Run: `uv run ruff check better_memory/services/relevant.py tests/services/test_relevant.py && uv run pyright better_memory/services/relevant.py` +Expected: clean. +```bash +git add better_memory/services/relevant.py tests/services/test_relevant.py +git commit -m "feat(relevant): retrieve_relevant fetch+filter+rank + formatter" +``` + +--- + +## Task 3: `format_relevant` token cap + +**Confidence: 96%** — small pure formatting addition + test. + +**Files:** +- Modify: `better_memory/services/relevant.py` +- Test: `tests/services/test_relevant_format.py` + +- [ ] **Step 1: Write failing test** + +```python +# tests/services/test_relevant_format.py +from __future__ import annotations + +from better_memory.services.relevant import RelevantMemory, format_relevant + + +def test_empty_returns_empty_string(): + assert format_relevant([]) == "" + + +def test_caps_items(): + items = [RelevantMemory("semantic", str(i), f"memory {i}", None, 1) for i in range(10)] + out = format_relevant(items, max_items=5) + assert out.count("•") == 5 + + +def test_includes_confidence_for_reflections(): + out = format_relevant([RelevantMemory("reflection", "r1", "do the thing", 0.9, 2)]) + assert "conf 0.90" in out +``` + +- [ ] **Step 2: Run — expect failure** (`format_relevant` has no `max_items`). + +Run: `uv run pytest tests/services/test_relevant_format.py -v` +Expected: FAIL (unexpected keyword `max_items`). + +- [ ] **Step 3: Add the cap param** + +Replace `format_relevant` signature/body in `relevant.py`: + +```python +def format_relevant(items: list[RelevantMemory], *, max_items: int = 5) -> str: + """Render the additionalContext block (≤ max_items). Empty if no items.""" + if not items: + return "" + lines = ["RELEVANT MEMORY — apply unless it conflicts with the user's request:"] + for m in items[:max_items]: + tag = f"{m.kind}" + (f" · conf {m.confidence:.2f}" if m.confidence is not None else "") + lines.append(f"• [{tag}] {m.summary}") + return "\n".join(lines) +``` + +- [ ] **Step 4: Run — expect pass.** + +Run: `uv run pytest tests/services/test_relevant_format.py tests/services/test_relevant.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/services/relevant.py tests/services/test_relevant_format.py +git commit -m "feat(relevant): cap formatted output to max_items" +``` + +--- + +## Task 4: Config — `context_inject_mode` + +**Confidence: 95%** — mirrors the existing `_resolve_embeddings_backend` resolver. Docs updated in-task per the keep-docs-in-sync guardrail. + +**Files:** +- Modify: `better_memory/config.py` +- Modify: `website/configuration.md` +- Test: `tests/test_config.py` (append) + +- [ ] **Step 1: Write failing test** + +```python +# tests/test_config.py (append) +import os +import pytest +from better_memory import config as cfg_mod + + +@pytest.mark.parametrize("val,expected", [ + (None, "both"), ("userprompt", "userprompt"), ("pretool", "pretool"), + ("both", "both"), ("off", "off"), +]) +def test_context_inject_mode_valid(monkeypatch, val, expected): + if val is None: + monkeypatch.delenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", raising=False) + else: + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", val) + assert cfg_mod._resolve_context_inject_mode() == expected + + +def test_context_inject_mode_invalid(monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", "bogus") + with pytest.raises(ValueError): + cfg_mod._resolve_context_inject_mode() +``` + +- [ ] **Step 2: Run — expect failure.** + +Run: `uv run pytest tests/test_config.py -k context_inject -v` +Expected: FAIL (`_resolve_context_inject_mode` missing). + +- [ ] **Step 3: Implement resolver + Config field** + +In `better_memory/config.py`, add near the other resolvers: + +```python +_DEFAULT_CONTEXT_INJECT_MODE = "both" +_VALID_CONTEXT_INJECT_MODES = ("userprompt", "pretool", "both", "off") + + +def _resolve_context_inject_mode() -> str: + raw = os.environ.get( + "BETTER_MEMORY_CONTEXT_INJECT_MODE", _DEFAULT_CONTEXT_INJECT_MODE + ) + if raw not in _VALID_CONTEXT_INJECT_MODES: + raise ValueError( + f"BETTER_MEMORY_CONTEXT_INJECT_MODE must be one of " + f"{_VALID_CONTEXT_INJECT_MODES}, got {raw!r}" + ) + return raw +``` + +Add a field to the `Config` dataclass: `context_inject_mode: str` and set it in `get_config()`: `context_inject_mode=_resolve_context_inject_mode(),`. If the module docstring enumerates env vars, add this one there too (keep-docs-in-sync). + +- [ ] **Step 4: Update `website/configuration.md`** + +Add a row to the env-var table: + +```markdown +| `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory injection hook trigger: `userprompt`, `pretool`, `both`, or `off`. | +``` + +- [ ] **Step 5: Run — expect pass + commit** + +Run: `uv run pytest tests/test_config.py -k context_inject -v` +Expected: PASS. +```bash +git add better_memory/config.py tests/test_config.py website/configuration.md +git commit -m "feat(config): BETTER_MEMORY_CONTEXT_INJECT_MODE switch" +``` + +--- + +## Task 5: The hook — `contextual_inject` + +**Confidence: 92%** — mirrors `session_bootstrap.py` exactly (stdin → parse → service → JSON envelope → exit 0). Mitigation for the dual-event + mode gating: tests feed both a `UserPromptSubmit` and a `PreToolUse` payload AND a forced-error case, asserting correct `hookEventName`, mode no-op, and always-exit-0. + +**Files:** +- Create: `better_memory/hooks/contextual_inject.py` +- Test: `tests/hooks/test_contextual_inject.py` + +- [ ] **Step 1: Write failing tests** (subprocess-style via `main()` with patched stdin/stdout) + +```python +# tests/hooks/test_contextual_inject.py +from __future__ import annotations + +import io +import json +import sys + +import pytest + +from better_memory.hooks import contextual_inject as hook + + +def _run(payload: dict, monkeypatch, capsys, mode="both"): + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", mode) + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload))) + with pytest.raises(SystemExit) as e: + hook.main() + assert e.value.code == 0 + out = capsys.readouterr().out + return json.loads(out) if out.strip() else {} + + +def test_userprompt_emits_envelope(monkeypatch, capsys): + res = _run({"hook_event_name": "UserPromptSubmit", "prompt": "write the plan", + "cwd": "."}, monkeypatch, capsys) + assert res["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit" + assert "additionalContext" in res["hookSpecificOutput"] + + +def test_mode_off_is_noop(monkeypatch, capsys): + res = _run({"hook_event_name": "UserPromptSubmit", "prompt": "write the plan", + "cwd": "."}, monkeypatch, capsys, mode="off") + assert res["hookSpecificOutput"]["additionalContext"] == "" + + +def test_pretool_disabled_when_mode_userprompt(monkeypatch, capsys): + res = _run({"hook_event_name": "PreToolUse", "tool_name": "Skill", + "tool_input": {"skill": "writing-plans"}, "cwd": "."}, + monkeypatch, capsys, mode="userprompt") + assert res["hookSpecificOutput"]["additionalContext"] == "" + + +def test_never_throws_on_garbage(monkeypatch, capsys): + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", "both") + monkeypatch.setattr(sys, "stdin", io.StringIO("not json")) + with pytest.raises(SystemExit) as e: + hook.main() + assert e.value.code == 0 +``` + +- [ ] **Step 2: Run — expect failure.** + +Run: `uv run pytest tests/hooks/test_contextual_inject.py -v` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement the hook** + +```python +# better_memory/hooks/contextual_inject.py +"""UserPromptSubmit / PreToolUse hook: inject curated memories relevant to the +current prompt or tool-input. Gated by BETTER_MEMORY_CONTEXT_INJECT_MODE +(userprompt | pretool | both | off). Never raises; always exits 0. + +NOTE (Task 0): whether PreToolUse fires for the built-in Skill/Task tools is +environment-dependent; UserPromptSubmit is the reliable trigger. +""" +from __future__ import annotations + +import json +import os +import sys +from contextlib import closing +from pathlib import Path + +from better_memory.config import get_config, project_name +from better_memory.db.connection import connect +from better_memory.hooks._error_log import record_hook_error +from better_memory.services.relevant import format_relevant, retrieve_relevant +from better_memory.storage import build_backend + +_MAX_STDIN_BYTES = 1_000_000 + + +def _enabled(event: str, mode: str) -> bool: + if mode == "off": + return False + if event == "UserPromptSubmit": + return mode in ("userprompt", "both") + if event == "PreToolUse": + return mode in ("pretool", "both") + return False + + +def _query_from(payload: dict, event: str) -> str: + if event == "UserPromptSubmit": + return str(payload.get("prompt") or "") + if event == "PreToolUse": + return f"{payload.get('tool_name') or ''} {json.dumps(payload.get('tool_input') or {})}" + return "" + + +def main() -> None: + raw = "" + try: + raw = sys.stdin.read(_MAX_STDIN_BYTES + 1) + except BaseException: # noqa: BLE001 — hooks never fail + pass + payload: dict = {} + if raw.strip() and len(raw) <= _MAX_STDIN_BYTES: + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + payload = parsed + except BaseException: # noqa: BLE001 + pass + + event = str(payload.get("hook_event_name") or "UserPromptSubmit") + rendered = "" + try: + cfg = get_config() + if _enabled(event, cfg.context_inject_mode): + query = _query_from(payload, event) + cwd = str(payload.get("cwd") or os.getcwd()) + project = project_name(Path(cwd)) + with closing(connect(cfg.memory_db)) as conn: + backend = build_backend(config=cfg, memory_conn=conn, + embedder=None, session_id=None, project=project) + items = retrieve_relevant(backend, query=query, project=project, limit=5) + rendered = format_relevant(items) + except BaseException as exc: # noqa: BLE001 + try: + record_hook_error(hook_name="contextual_inject", exc=exc) + except BaseException: # noqa: BLE001 + pass + rendered = "" + + try: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": event, "additionalContext": rendered}}), flush=True) + except BaseException: # noqa: BLE001 + pass + sys.exit(0) + + +if __name__ == "__main__": + main() +``` + +> **Verify-before-commit:** confirm `build_backend`'s exact kwargs in `better_memory/storage/__init__.py` and that `embedder=None`/`session_id=None` are acceptable for a read-only retrieve (the sqlite backend doesn't need an embedder for `retrieve`/`semantic_list`). Adjust the call to match the real signature if it differs. + +- [ ] **Step 4: Run — expect pass.** + +Run: `uv run pytest tests/hooks/test_contextual_inject.py -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Lint/typecheck + commit** + +Run: `uv run ruff check better_memory/hooks/contextual_inject.py tests/hooks/test_contextual_inject.py && uv run pyright better_memory/hooks/contextual_inject.py` +Expected: clean. +```bash +git add better_memory/hooks/contextual_inject.py tests/hooks/test_contextual_inject.py +git commit -m "feat(hooks): contextual_inject UserPromptSubmit/PreToolUse hook" +``` + +--- + +## Task 6: Registration + setup docs + +**Confidence: 94%** — config/doc edits; mirrors existing hook registration. Keep-docs-in-sync guardrail satisfied here. + +**Files:** +- Modify: `docs/hooks-setup.md` +- Modify: `README.md` +- Modify: `scripts/setup.sh` (if it writes the hook block) + +- [ ] **Step 1: Add the hook entries to `docs/hooks-setup.md`** + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { "hooks": [ { "type": "command", + "command": "uv run python -m better_memory.hooks.contextual_inject" } ] } + ], + "PreToolUse": [ + { "matcher": "Skill|Task|Write", + "hooks": [ { "type": "command", + "command": "uv run python -m better_memory.hooks.contextual_inject" } ] } + ] + } +} +``` +Document `BETTER_MEMORY_CONTEXT_INJECT_MODE` (userprompt|pretool|both|off, default both) and note the Task 0 finding about Skill/Task `PreToolUse` firing. + +- [ ] **Step 2: Update `README.md`** — add `contextual_inject` to the hooks list (the repo README lists the hooks; keep the count/table accurate per the keep-docs-in-sync guardrail). + +- [ ] **Step 3: Update `scripts/setup.sh`** if it programmatically writes the hooks block — add the two entries above. + +- [ ] **Step 4: Commit** + +```bash +git add docs/hooks-setup.md README.md scripts/setup.sh +git commit -m "docs: register contextual_inject hook (UserPromptSubmit + PreToolUse)" +``` + +--- + +## Task 7: (Optional) MCP tool `memory.retrieve_relevant` + +**Confidence: 90%** — mirrors the existing handler/registry pattern. Mitigation: verify exact `TextContent` import + registry wiring from `mcp/handlers/reflections.py` before writing; bump tool-count docs per guardrail. + +**Files:** +- Create/modify: `better_memory/mcp/handlers/relevant.py` + register in `mcp/server.py` +- Modify: `website/mcp-tools.md`, `README.md` (tool count) +- Test: `tests/mcp/test_relevant_tool.py` + +- [ ] **Step 1: Write failing test** (handler returns TextContent JSON for matches) — seed a backend like Task 2, call the handler, assert it returns the formatted block. + +```python +# tests/mcp/test_relevant_tool.py +import pytest +from better_memory.mcp.handlers.relevant import RelevantToolHandlers +# ... build a SqliteBackend (as in tests/services/test_relevant.py), seed memories ... + +@pytest.mark.asyncio +async def test_retrieve_relevant_tool_returns_matches(backend): + handler = RelevantToolHandlers(backend=backend, project="proj") + out = await handler.retrieve({"query": "write the plan"}) + assert out and "plan" in out[0].text.lower() +``` + +- [ ] **Step 2: Run — expect failure.** +Run: `uv run pytest tests/mcp/test_relevant_tool.py -v` → FAIL. + +- [ ] **Step 3: Implement handler + register** + +```python +# better_memory/mcp/handlers/relevant.py +from __future__ import annotations +from typing import Any +from mcp.types import TextContent +from better_memory.services.relevant import format_relevant, retrieve_relevant + + +class RelevantToolHandlers: + def __init__(self, *, backend: Any, project: str) -> None: + self._backend = backend + self._project = project + + def tools(self) -> dict[str, Any]: + return {"memory.retrieve_relevant": self.retrieve} + + async def retrieve(self, args: dict[str, Any]) -> list[TextContent]: + query = str(args.get("query") or "") + limit = int(args.get("limit") or 5) + items = retrieve_relevant(self._backend, query=query, + project=self._project, limit=limit) + return [TextContent(type="text", text=format_relevant(items))] +``` +Register in `mcp/server.py` `build_registry(...)`: `RelevantToolHandlers(backend=backend, project=startup_project),` and add the tool's input schema where tool schemas are declared. + +- [ ] **Step 4: Run — expect pass.** `uv run pytest tests/mcp/test_relevant_tool.py -v` → PASS. + +- [ ] **Step 5: Docs (keep-docs-in-sync)** — add `memory.retrieve_relevant` to `website/mcp-tools.md`; bump the tool count in `README.md` ("registers N tools") and `website/index.md`. + +- [ ] **Step 6: Commit** + +```bash +git add better_memory/mcp/handlers/relevant.py better_memory/mcp/server.py tests/mcp/test_relevant_tool.py website/mcp-tools.md README.md website/index.md +git commit -m "feat(mcp): memory.retrieve_relevant tool" +``` + +--- + +## Task 8: Full suite, lint, PR + +**Confidence: 95%.** + +- [ ] **Step 1: Full gate** + +Run: `uv run ruff check . && uv run pyright && uv run pytest -q` +Expected: clean / all pass. + +- [ ] **Step 2: Manual smoke** — pipe a sample payload through the hook: + +Run (bash): `echo '{"hook_event_name":"UserPromptSubmit","prompt":"lets write the plan","cwd":"."}' | uv run python -m better_memory.hooks.contextual_inject` +Expected: a JSON envelope; if planning memories exist for the project, `additionalContext` contains them; exit 0. + +- [ ] **Step 3: Commit any fixups, push, open PR** + +```bash +git push -u origin feat/contextual-memory-injection-hook +gh pr create --base main --title "Contextual memory injection hook (UserPromptSubmit/PreToolUse)" --body "Implements docs/superpowers/specs/2026-06-14-contextual-memory-injection-hook-design.md" +``` + +--- + +## Self-Review + +- **Spec coverage:** `retrieve_relevant` (T2), keyword/whole-word (T1), formatter+cap (T2/T3), config mode switch (T4), hook for both events + gating (T5), registration/setup docs (T6), optional MCP tool (T7), gate/PR (T8), Skill-PreToolUse empirical check (T0). Scope = semantic + reflections, project + general, no-dedup, empty→nothing, `track_exposure=False`, backend-agnostic via abstraction — all covered. +- **Placeholder scan:** every code step has full code; commands have expected output. (T7 marked optional.) +- **Type consistency:** `RelevantMemory(kind,id,summary,confidence,hits)`, `retrieve_relevant(backend,*,query,project,limit,include_neutral)`, `format_relevant(items,*,max_items)`, `_resolve_context_inject_mode()`, `cfg.context_inject_mode`, hook `_enabled/_query_from/main` — names consistent across T1–T7. +- **Lint:** test code uses `from __future__ import annotations`; no `(str, Enum)`; no `datetime.timezone.utc`. py312 ruff UP-safe. From 3db0eeb5edfb46a20706856971e56d16feaf57a7 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:45:10 +0100 Subject: [PATCH 05/11] feat(relevant): keyword extraction + whole-word matching --- better_memory/services/keywords.py | 42 ++++++++++++++++++++++++++++++ tests/services/test_keywords.py | 35 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 better_memory/services/keywords.py create mode 100644 tests/services/test_keywords.py diff --git a/better_memory/services/keywords.py b/better_memory/services/keywords.py new file mode 100644 index 0000000..4d9761d --- /dev/null +++ b/better_memory/services/keywords.py @@ -0,0 +1,42 @@ +"""Keyword extraction + whole-word matching for contextual memory relevance. + +Pure, dependency-free. Used by retrieve_relevant to filter the curated memory +set (semantic + reflections) against the current prompt / tool-input. +""" +from __future__ import annotations + +import re + +# Small, deliberately conservative stopword set. Tunable. +_STOPWORDS = frozenset({ + "the", "and", "for", "are", "was", "were", "you", "your", "our", "with", + "this", "that", "from", "into", "have", "has", "had", "but", "not", "can", + "will", "would", "should", "could", "lets", "let", "get", "got", "out", + "use", "using", "what", "when", "how", "why", "who", "all", "any", "its", + "his", "her", "they", "them", "then", "than", "now", "via", "per", +}) + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def extract_keywords(text: str) -> set[str]: + """Lowercase, tokenise on non-alphanumerics, drop stopwords + <3-char tokens.""" + if not text: + return set() + return { + tok + for tok in _TOKEN_RE.findall(text.lower()) + if len(tok) >= 3 and tok not in _STOPWORDS + } + + +def count_keyword_hits(text: str, keywords: set[str]) -> int: + """Number of distinct keywords that appear as a WHOLE WORD in text.""" + if not text or not keywords: + return 0 + lowered = text.lower() + hits = 0 + for kw in keywords: + if re.search(rf"\b{re.escape(kw)}\b", lowered): + hits += 1 + return hits diff --git a/tests/services/test_keywords.py b/tests/services/test_keywords.py new file mode 100644 index 0000000..0bc4a5d --- /dev/null +++ b/tests/services/test_keywords.py @@ -0,0 +1,35 @@ +"""Tests for keyword extraction + whole-word matching.""" +from __future__ import annotations + +from better_memory.services.keywords import count_keyword_hits, extract_keywords + + +class TestExtractKeywords: + def test_lowercases_and_splits(self): + assert extract_keywords("Write the Plan") == {"write", "plan"} + + def test_drops_stopwords_and_short_tokens(self): + kw = extract_keywords("we are on to the CI plan") + assert "plan" in kw + assert "the" not in kw and "are" not in kw + assert "ci" not in kw # 2-char tokens dropped (documented tunable) + + def test_dedupes(self): + assert extract_keywords("plan plan PLAN") == {"plan"} + + def test_empty(self): + assert extract_keywords(" ") == set() + + +class TestCountKeywordHits: + def test_whole_word_only(self): + kw = {"art", "plan"} + # 'art' in 'start', 'plan' in 'planner' — neither is a whole word + assert count_keyword_hits("let us start the planner", kw) == 0 + assert count_keyword_hits("the art of a plan", kw) == 2 + + def test_case_insensitive_and_punctuation(self): + assert count_keyword_hits("Finalise the Plan.", {"plan"}) == 1 + + def test_distinct_terms_counted_once_each(self): + assert count_keyword_hits("plan plan plan", {"plan"}) == 1 From a3e63fa4a88bf40da1ce875f2809e709e98b53f2 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:49:12 +0100 Subject: [PATCH 06/11] feat(relevant): retrieve_relevant fetch+filter+rank + formatter --- better_memory/services/relevant.py | 102 +++++++++++++++++++++++++++++ tests/services/test_relevant.py | 60 +++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 better_memory/services/relevant.py create mode 100644 tests/services/test_relevant.py diff --git a/better_memory/services/relevant.py b/better_memory/services/relevant.py new file mode 100644 index 0000000..f5e58fb --- /dev/null +++ b/better_memory/services/relevant.py @@ -0,0 +1,102 @@ +"""Relevance filter over the curated memory set (semantic + reflections). + +Fetches the small, already-ranked sets through the StorageBackend abstraction +(works on sqlite AND agentcore), whole-word keyword-filters them against a query, +and returns the top matches. Pure-Python; no embeddings, no new schema. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from better_memory.services.keywords import count_keyword_hits, extract_keywords + + +@dataclass +class RelevantMemory: + kind: str # "reflection" | "semantic" + id: str + summary: str # short display text + confidence: float | None + hits: int # distinct keyword hits (higher = more relevant) + + +def _reflection_text(r: dict[str, Any]) -> str: + parts = [str(r.get("title") or ""), str(r.get("use_cases") or "")] + hints = r.get("hints") or [] + if isinstance(hints, list): + parts.extend(str(h) for h in hints) + return " ".join(parts) + + +def retrieve_relevant( + backend: Any, + *, + query: str, + project: str, + limit: int = 5, + include_neutral: bool = False, +) -> list[RelevantMemory]: + """Return up to ``limit`` curated memories whose text whole-word-matches a + keyword from ``query``, ordered by (# hits desc, managed rank asc). + + Never raises: any backend error yields an empty list (the hook must not break + a turn). "Managed rank" is the order the backend already returned items in + (confidence / useful-count): reflections flattened do -> dont -> [neutral], + then semantic. + """ + keywords = extract_keywords(query) + if not keywords: + return [] + + candidates: list[tuple[int, RelevantMemory]] = [] # (managed_rank, mem) + rank = 0 + + try: + buckets = backend.retrieve(project=project, track_exposure=False) + except Exception: # noqa: BLE001 — degrade to no reflections + buckets = {} + order = ["do", "dont"] + (["neutral"] if include_neutral else []) + for bucket in order: + for r in buckets.get(bucket, []) or []: + hits = count_keyword_hits(_reflection_text(r), keywords) + if hits: + candidates.append((rank, RelevantMemory( + kind="reflection", + id=str(r.get("id")), + summary=str(r.get("title") or _reflection_text(r))[:160], + confidence=r.get("confidence"), + hits=hits, + ))) + rank += 1 + + try: + semantic = backend.semantic_list(project=project, track_exposure=False) + except Exception: # noqa: BLE001 — degrade to no semantic + semantic = [] + for s in semantic or []: + content = getattr(s, "content", "") or "" + hits = count_keyword_hits(content, keywords) + if hits: + candidates.append((rank, RelevantMemory( + kind="semantic", + id=str(getattr(s, "id", "")), + summary=content[:160], + confidence=None, + hits=hits, + ))) + rank += 1 + + candidates.sort(key=lambda t: (-t[1].hits, t[0])) + return [m for _, m in candidates[:limit]] + + +def format_relevant(items: list[RelevantMemory], *, max_items: int = 5) -> str: + """Render the additionalContext block (<= max_items). Empty if no items.""" + if not items: + return "" + lines = ["RELEVANT MEMORY — apply unless it conflicts with the user's request:"] + for m in items[:max_items]: + tag = m.kind + (f" · conf {m.confidence:.2f}" if m.confidence is not None else "") + lines.append(f"• [{tag}] {m.summary}") + return "\n".join(lines) diff --git a/tests/services/test_relevant.py b/tests/services/test_relevant.py new file mode 100644 index 0000000..774d235 --- /dev/null +++ b/tests/services/test_relevant.py @@ -0,0 +1,60 @@ +"""Tests for retrieve_relevant over a real sqlite StorageBackend.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from better_memory.db.connection import connect +from better_memory.db.schema import apply_migrations +from better_memory.services.relevant import RelevantMemory, retrieve_relevant +from better_memory.services.semantic import SemanticMemoryService +from better_memory.storage.sqlite import SqliteBackend + + +@pytest.fixture +def backend(tmp_memory_db: Path): + conn = connect(tmp_memory_db) + apply_migrations(conn) + sem = SemanticMemoryService(conn) + sem.create(content="Always write the implementation plan with confidence scores", + project="proj", scope="project") + sem.create(content="Never ask the user to babysit a PR", project="other", scope="general") + sem.create(content="Prefer tea over coffee", project="proj", scope="project") + try: + yield SqliteBackend(memory_conn=conn, embedder=None, session_id=None, project="proj") + finally: + conn.close() + + +def test_filters_to_keyword_matches(backend): + out = retrieve_relevant(backend, query="let us write the plan", project="proj", limit=5) + texts = " ".join(m.summary.lower() for m in out) + assert "plan" in texts + assert "coffee" not in texts # irrelevant memory excluded + + +def test_includes_general_scope(backend): + out = retrieve_relevant(backend, query="babysit the PR", project="proj", limit=5) + assert any("babysit" in m.summary.lower() for m in out) # general-scope semantic matched + + +def test_empty_on_no_match(backend): + assert retrieve_relevant(backend, query="xylophone zeppelin", project="proj", limit=5) == [] + + +def test_empty_on_empty_query(backend): + assert retrieve_relevant(backend, query=" ", project="proj", limit=5) == [] + + +def test_respects_limit(backend): + out = retrieve_relevant( + backend, query="plan confidence babysit pr user", project="proj", limit=1 + ) + assert len(out) == 1 + + +def test_returns_relevantmemory(backend): + out = retrieve_relevant(backend, query="plan", project="proj", limit=5) + assert out and all(isinstance(m, RelevantMemory) for m in out) + assert all(m.kind in ("reflection", "semantic") for m in out) From 59c9e74c5906f214aa4b6756c9a1dc5e9af5307a Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:49:36 +0100 Subject: [PATCH 07/11] test(relevant): formatter cap + confidence tag --- tests/services/test_relevant_format.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 tests/services/test_relevant_format.py diff --git a/tests/services/test_relevant_format.py b/tests/services/test_relevant_format.py new file mode 100644 index 0000000..8a47a39 --- /dev/null +++ b/tests/services/test_relevant_format.py @@ -0,0 +1,24 @@ +"""Tests for format_relevant.""" +from __future__ import annotations + +from better_memory.services.relevant import RelevantMemory, format_relevant + + +def test_empty_returns_empty_string(): + assert format_relevant([]) == "" + + +def test_caps_items(): + items = [RelevantMemory("semantic", str(i), f"memory {i}", None, 1) for i in range(10)] + out = format_relevant(items, max_items=5) + assert out.count("•") == 5 + + +def test_includes_confidence_for_reflections(): + out = format_relevant([RelevantMemory("reflection", "r1", "do the thing", 0.9, 2)]) + assert "conf 0.90" in out + + +def test_semantic_has_no_confidence_tag(): + out = format_relevant([RelevantMemory("semantic", "s1", "a fact", None, 1)]) + assert "conf" not in out From 8bf92b1a49920b0109b4f28b6fd75cde38e442bf Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:50:15 +0100 Subject: [PATCH 08/11] test(relevant): fix substring collision (conflicts vs conf tag) --- tests/services/test_relevant_format.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/services/test_relevant_format.py b/tests/services/test_relevant_format.py index 8a47a39..9ec1826 100644 --- a/tests/services/test_relevant_format.py +++ b/tests/services/test_relevant_format.py @@ -21,4 +21,4 @@ def test_includes_confidence_for_reflections(): def test_semantic_has_no_confidence_tag(): out = format_relevant([RelevantMemory("semantic", "s1", "a fact", None, 1)]) - assert "conf" not in out + assert "· conf" not in out # the confidence tag, not the word "conflicts" in the header From 60cf0a8cff1dec8a0f5883c12f100bccb1f63844 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:52:46 +0100 Subject: [PATCH 09/11] feat(config): BETTER_MEMORY_CONTEXT_INJECT_MODE switch + docs --- better_memory/config.py | 18 ++++++++++++++++++ tests/test_config.py | 20 ++++++++++++++++++++ website/configuration.md | 1 + 3 files changed, 39 insertions(+) diff --git a/better_memory/config.py b/better_memory/config.py index a704aee..f66aa9f 100644 --- a/better_memory/config.py +++ b/better_memory/config.py @@ -212,6 +212,23 @@ class Config: agentcore_region: str agentcore_semantic_memory_id: str | None agentcore_episodic_memory_id: str | None + context_inject_mode: Literal["userprompt", "pretool", "both", "off"] + + +_DEFAULT_CONTEXT_INJECT_MODE = "both" +_VALID_CONTEXT_INJECT_MODES = ("userprompt", "pretool", "both", "off") + + +def _resolve_context_inject_mode() -> Literal["userprompt", "pretool", "both", "off"]: + raw = os.environ.get( + "BETTER_MEMORY_CONTEXT_INJECT_MODE", _DEFAULT_CONTEXT_INJECT_MODE + ) + if raw not in _VALID_CONTEXT_INJECT_MODES: + raise ValueError( + f"BETTER_MEMORY_CONTEXT_INJECT_MODE must be one of " + f"{_VALID_CONTEXT_INJECT_MODES}, got {raw!r}" + ) + return raw # type: ignore[return-value] def _resolve_embeddings_backend() -> Literal["ollama", "sqlite"]: @@ -279,4 +296,5 @@ def get_config() -> Config: agentcore_region=agentcore_region, agentcore_semantic_memory_id=agentcore_semantic_memory_id, agentcore_episodic_memory_id=agentcore_episodic_memory_id, + context_inject_mode=_resolve_context_inject_mode(), ) diff --git a/tests/test_config.py b/tests/test_config.py index df72677..19bb003 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -396,3 +396,23 @@ def test_project_name_caches_negative_lookup(tmp_path: Path) -> None: assert bm_config.project_name(nongit) == "general" assert resolved in bm_config._git_project_cache assert bm_config._git_project_cache[resolved] is None + + +@pytest.mark.parametrize("val,expected", [ + (None, "both"), ("userprompt", "userprompt"), ("pretool", "pretool"), + ("both", "both"), ("off", "off"), +]) +def test_context_inject_mode_valid(monkeypatch, val, expected): + from better_memory import config as cfg_mod + if val is None: + monkeypatch.delenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", raising=False) + else: + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", val) + assert cfg_mod._resolve_context_inject_mode() == expected + + +def test_context_inject_mode_invalid(monkeypatch): + from better_memory import config as cfg_mod + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", "bogus") + with pytest.raises(ValueError): + cfg_mod._resolve_context_inject_mode() diff --git a/website/configuration.md b/website/configuration.md index 3644c42..686a658 100644 --- a/website/configuration.md +++ b/website/configuration.md @@ -17,6 +17,7 @@ One environment variable roots the runtime filesystem layout. Everything else ha | `BETTER_MEMORY_AGENTCORE_REGION` | `eu-west-2` | AWS region for `bedrock-agentcore` / `bedrock-agentcore-control` clients when in `agentcore` mode. Only `eu-west-2` is verified by the maintainers; other regions may work if Bedrock AgentCore Memory is GA there. | | `BETTER_MEMORY_TEST_AGENTCORE` | unset | `1` enables integration tests against real AWS. Default off; never set in CI. | | `BETTER_MEMORY_TEST_AGENTCORE_REGION` | inherits `eu-west-2` | Override region used by integration tests. | +| `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt` (on prompt only), `pretool` (on tool calls only), `both` (default), or `off`. The `contextual_inject` hook surfaces curated memories (semantic + reflections) relevant to the current prompt / tool-input. | ## Project-name override From 2e0ecfe98502f6648765308b224ee5e6e80c2cb1 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 21:54:01 +0100 Subject: [PATCH 10/11] feat(hooks): contextual_inject UserPromptSubmit/PreToolUse hook --- better_memory/hooks/contextual_inject.py | 103 +++++++++++++++++++++++ tests/hooks/test_contextual_inject.py | 55 ++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 better_memory/hooks/contextual_inject.py create mode 100644 tests/hooks/test_contextual_inject.py diff --git a/better_memory/hooks/contextual_inject.py b/better_memory/hooks/contextual_inject.py new file mode 100644 index 0000000..8ef6eca --- /dev/null +++ b/better_memory/hooks/contextual_inject.py @@ -0,0 +1,103 @@ +"""UserPromptSubmit / PreToolUse hook: inject curated memories relevant to the +current prompt or tool-input. Gated by BETTER_MEMORY_CONTEXT_INJECT_MODE +(userprompt | pretool | both | off). Never raises; always exits 0. + +NOTE: whether PreToolUse fires for the built-in Skill/Task tools is +environment-dependent (see the plan's Task 0 probe); UserPromptSubmit is the +reliable trigger. +""" +from __future__ import annotations + +import json +import os +import sys +from contextlib import closing +from pathlib import Path + +from better_memory.config import get_config, project_name +from better_memory.db.connection import connect +from better_memory.hooks._error_log import record_hook_error +from better_memory.services.relevant import format_relevant, retrieve_relevant +from better_memory.storage import build_backend + +_MAX_STDIN_BYTES = 1_000_000 + + +def _enabled(event: str, mode: str) -> bool: + if mode == "off": + return False + if event == "UserPromptSubmit": + return mode in ("userprompt", "both") + if event == "PreToolUse": + return mode in ("pretool", "both") + return False + + +def _query_from(payload: dict, event: str) -> str: + if event == "UserPromptSubmit": + return str(payload.get("prompt") or "") + if event == "PreToolUse": + tool = payload.get("tool_name") or "" + return f"{tool} {json.dumps(payload.get('tool_input') or {})}" + return "" + + +def main() -> None: + raw = "" + try: + raw = sys.stdin.read(_MAX_STDIN_BYTES + 1) + except BaseException: # noqa: BLE001 — hooks never fail + pass + payload: dict = {} + if raw.strip() and len(raw) <= _MAX_STDIN_BYTES: + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + payload = parsed + except BaseException: # noqa: BLE001 + pass + + event = str(payload.get("hook_event_name") or "UserPromptSubmit") + rendered = "" + try: + cfg = get_config() + if _enabled(event, cfg.context_inject_mode): + query = _query_from(payload, event) + cwd = str(payload.get("cwd") or os.getcwd()) + project = project_name(Path(cwd)) + with closing(connect(cfg.memory_db)) as conn: + backend = build_backend( + config=cfg, + memory_conn=conn, + embedder=None, + session_id=None, + project=project, + ) + items = retrieve_relevant( + backend, query=query, project=project, limit=5 + ) + rendered = format_relevant(items) + except BaseException as exc: # noqa: BLE001 + try: + record_hook_error(hook_name="contextual_inject", exc=exc) + except BaseException: # noqa: BLE001 + pass + rendered = "" + + try: + print( + json.dumps({ + "hookSpecificOutput": { + "hookEventName": event, + "additionalContext": rendered, + } + }), + flush=True, + ) + except BaseException: # noqa: BLE001 + pass + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/hooks/test_contextual_inject.py b/tests/hooks/test_contextual_inject.py new file mode 100644 index 0000000..580fc5d --- /dev/null +++ b/tests/hooks/test_contextual_inject.py @@ -0,0 +1,55 @@ +"""Tests for the contextual_inject hook.""" +from __future__ import annotations + +import io +import json +import sys + +import pytest + +from better_memory.hooks import contextual_inject as hook + + +def _run(payload: dict, monkeypatch, capsys, mode="both"): + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", mode) + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload))) + with pytest.raises(SystemExit) as e: + hook.main() + assert e.value.code == 0 + out = capsys.readouterr().out + return json.loads(out) if out.strip() else {} + + +def test_userprompt_emits_envelope(monkeypatch, capsys): + res = _run({"hook_event_name": "UserPromptSubmit", "prompt": "write the plan", + "cwd": "."}, monkeypatch, capsys) + assert res["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit" + assert "additionalContext" in res["hookSpecificOutput"] + + +def test_mode_off_is_noop(monkeypatch, capsys): + res = _run({"hook_event_name": "UserPromptSubmit", "prompt": "write the plan", + "cwd": "."}, monkeypatch, capsys, mode="off") + assert res["hookSpecificOutput"]["additionalContext"] == "" + + +def test_pretool_disabled_when_mode_userprompt(monkeypatch, capsys): + res = _run({"hook_event_name": "PreToolUse", "tool_name": "Skill", + "tool_input": {"skill": "writing-plans"}, "cwd": "."}, + monkeypatch, capsys, mode="userprompt") + assert res["hookSpecificOutput"]["additionalContext"] == "" + + +def test_pretool_event_echoed(monkeypatch, capsys): + res = _run({"hook_event_name": "PreToolUse", "tool_name": "Skill", + "tool_input": {"skill": "writing-plans"}, "cwd": "."}, + monkeypatch, capsys, mode="both") + assert res["hookSpecificOutput"]["hookEventName"] == "PreToolUse" + + +def test_never_throws_on_garbage(monkeypatch, capsys): + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", "both") + monkeypatch.setattr(sys, "stdin", io.StringIO("not json")) + with pytest.raises(SystemExit) as e: + hook.main() + assert e.value.code == 0 From 6e5a20ab5cd07dfaa747a7eb9c5151c67d72f2a5 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 14 Jun 2026 22:01:28 +0100 Subject: [PATCH 11/11] feat(hooks): register contextual_inject (UserPromptSubmit + PreToolUse) in installer + docs --- README.md | 2 +- better_memory/cli/install_hooks.py | 23 ++++++++++++++++++++-- docs/hooks-setup.md | 31 +++++++++++++++++++++++++++++- tests/cli/test_install_hooks.py | 17 ++++++++++++---- 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4e7796f..0e789a1 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The script: 3. Checks for Ollama; offers to install via `brew` / `apt` / `winget` if missing. 4. Pulls `nomic-embed-text`. 5. Creates `~/.better-memory/{spool,knowledge-base/...}`. -6. Auto-installs the MCP server registration into `~/.claude.json` and the three hooks (`session_bootstrap`, `observer`, `session_close`) into `~/.claude/settings.json` (idempotent; backups go to `~/.better-memory/install-backups/`). +6. Auto-installs the MCP server registration into `~/.claude.json` and the hooks (`session_bootstrap`, `observer`, `session_close`, and `contextual_inject` on `UserPromptSubmit` + `PreToolUse`) into `~/.claude/settings.json` (idempotent; backups go to `~/.better-memory/install-backups/`). If you'd rather inspect or hand-edit the config, see [Manual setup](#manual-setup) below. diff --git a/better_memory/cli/install_hooks.py b/better_memory/cli/install_hooks.py index 75b4939..28e4945 100644 --- a/better_memory/cli/install_hooks.py +++ b/better_memory/cli/install_hooks.py @@ -42,8 +42,8 @@ @dataclass(frozen=True) class HookSpec: module: str # e.g. "better_memory.hooks.session_start" - event: str # "SessionStart" | "PostToolUse" | "Stop" - matcher: str | None # None for SessionStart/Stop; "Write|Edit|Bash" for observer + event: str # a Claude Code hook event name + matcher: str | None # None for unscoped events; tool-name alternation otherwise is_async: bool # True for PostToolUse + Stop needs_stdout: bool # True for SessionStart bootstrap — Claude Code reads # the hook's stdout for additionalContext, so the @@ -56,6 +56,14 @@ class HookSpec: HookSpec("better_memory.hooks.session_bootstrap", "SessionStart", None, False, True), HookSpec("better_memory.hooks.observer", "PostToolUse", "Write|Edit|Bash", True, False), HookSpec("better_memory.hooks.session_close", "Stop", None, True, False), + # Contextual injection: surface curated memories relevant to the current + # prompt / tool-input. Sync + needs_stdout (reads additionalContext from + # stdout). Gated at runtime by BETTER_MEMORY_CONTEXT_INJECT_MODE; both + # events install and the mode no-ops whichever isn't selected. + HookSpec("better_memory.hooks.contextual_inject", "UserPromptSubmit", None, False, True), + HookSpec( + "better_memory.hooks.contextual_inject", "PreToolUse", "Skill|Task|Write", False, True + ), ) # Module paths that are no longer registered but may be present in users' @@ -183,6 +191,17 @@ def merge_settings_json( "hooks": [_hook_entry(spec, venv_py, venv_pyw)], }) + for spec in (s for s in _OUR_HOOKS if s.event == "UserPromptSubmit"): + hooks.setdefault("UserPromptSubmit", []).append({ + "hooks": [_hook_entry(spec, venv_py, venv_pyw)], + }) + + for spec in (s for s in _OUR_HOOKS if s.event == "PreToolUse"): + group = {"hooks": [_hook_entry(spec, venv_py, venv_pyw)]} + if spec.matcher is not None: + group["matcher"] = spec.matcher + hooks.setdefault("PreToolUse", []).append(group) + config["hooks"] = hooks return config diff --git a/docs/hooks-setup.md b/docs/hooks-setup.md index 0bf71e2..cf4d7c1 100644 --- a/docs/hooks-setup.md +++ b/docs/hooks-setup.md @@ -1,12 +1,20 @@ # better-memory hook registration -better-memory ships three hooks that wire into Claude Code's hook framework: +better-memory ships these hooks that wire into Claude Code's hook framework: | Hook | Purpose | Module | |---|---|---| | `SessionStart` | Open/reuse a background episode and inject project + general semantic memories and reflections as `additionalContext` | `better_memory.hooks.session_bootstrap` | | `PostToolUse` | Capture every tool invocation as a spool event | `better_memory.hooks.observer` | | `Stop` | Mark session end for consolidation boundary detection | `better_memory.hooks.session_close` | +| `UserPromptSubmit` + `PreToolUse` | Inject curated memories (semantic + reflections) relevant to the current prompt / tool-input as `additionalContext` | `better_memory.hooks.contextual_inject` | + +The `contextual_inject` hook is gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE` +(`userprompt` \| `pretool` \| `both` (default) \| `off`). It runs in-process +against `memory.db`, whole-word-matches the curated memory set against the +prompt / tool-input, and injects the top matches; it never blocks a turn. +Note: whether `PreToolUse` fires for the built-in `Skill`/`Task` tools is +environment-dependent — `UserPromptSubmit` is the reliable trigger. ## Registering the hooks @@ -49,6 +57,27 @@ project-scoped): } ] } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "uv run python -m better_memory.hooks.contextual_inject" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Skill|Task|Write", + "hooks": [ + { + "type": "command", + "command": "uv run python -m better_memory.hooks.contextual_inject" + } + ] + } ] } } diff --git a/tests/cli/test_install_hooks.py b/tests/cli/test_install_hooks.py index 7c14d61..c5449e4 100644 --- a/tests/cli/test_install_hooks.py +++ b/tests/cli/test_install_hooks.py @@ -82,10 +82,19 @@ def test_other_mcp_servers_untouched(self) -> None: class TestHookSpec: - """Sanity checks on the hook registry — pin the three expected entries.""" - - def test_registry_has_three_entries(self) -> None: - assert len(_OUR_HOOKS) == 3 + """Sanity checks on the hook registry — pin the expected entries.""" + + def test_registry_has_five_entries(self) -> None: + assert len(_OUR_HOOKS) == 5 + + def test_contextual_inject_registered_for_both_events(self) -> None: + ci = [s for s in _OUR_HOOKS if s.module.endswith("contextual_inject")] + events = {s.event for s in ci} + assert events == {"UserPromptSubmit", "PreToolUse"} + ups = next(s for s in ci if s.event == "UserPromptSubmit") + assert ups.matcher is None and ups.is_async is False and ups.needs_stdout is True + ptu = next(s for s in ci if s.event == "PreToolUse") + assert ptu.matcher == "Skill|Task|Write" and ptu.needs_stdout is True def test_session_bootstrap_is_session_start_event_no_matcher(self) -> None: sb = next(s for s in _OUR_HOOKS if s.module.endswith("session_bootstrap"))