From 4181b33006e0fadf6006890bb1c4c67536571fc2 Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 22:52:39 +0100 Subject: [PATCH 01/16] docs: just-in-time serving design spec and implementation plan Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-10-05-just-in-time-serving.md | 406 ++++++++++++++++++ .../2026-10-05-just-in-time-serving-design.md | 150 +++++++ 2 files changed, 556 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-just-in-time-serving.md create mode 100644 docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md diff --git a/docs/superpowers/plans/2026-10-05-just-in-time-serving.md b/docs/superpowers/plans/2026-10-05-just-in-time-serving.md new file mode 100644 index 0000000..dc3926f --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-just-in-time-serving.md @@ -0,0 +1,406 @@ +# Just-in-time serving 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:** Serve memories at the moment they apply (tool-call triggers), stop the session-start rule dump, tighten the prompt gate, and show the useful rate per channel so the 33% target is measurable. + +**Architecture:** A pure trigger grammar module matches tool name plus input; a nullable `triggers` JSON column on both memory tables feeds it; the PreToolUse hook loses its once-per-session latch and injects on trigger hits with exposure source `trigger`. `retrieve_relevant` gets one document-frequency-filtered distinct-hit gate for both memory kinds and skips non-human prompts. Deferred bootstrap renders no memories. The Diagnostics page gains a useful-rate-by-channel panel. + +**Tech Stack:** Python 3.12, sqlite3 (FTS5), Flask + Jinja + htmx (management UI), MCP server, pytest. Run everything through `uv run`. + +**Spec:** `docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md` + +## Global Constraints + +- Hooks never raise and always exit 0; failures go to `hook_errors` via `record_hook_error`. +- Agentcore backend keeps working with triggers absent: `supports_triggers` is `False`, `set_triggers` raises `NotImplementedError`, `triggered_candidates` returns `[]`. +- Constants from the spec: distinct-hit floor `cfg.context_min_hits` default 2 (`BETTER_MEMORY_CONTEXT_MIN_HITS`); `DF_CAP = 0.5`; `BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS` default 8000; new exposure source string `trigger`; new diagnostics metrics `contextual_suppressed_nonhuman`, `trigger_fired`, `trigger_injected`. +- Default `BETTER_MEMORY_INJECT_MODE` becomes `deferred`; legacy mode behaviour is untouched. +- Website docs change in the same branch as the code they describe. +- Commit after every task; message style `feat:` / `test:` / `docs:` as in `git log`. +- No time or effort estimates anywhere in plan or commits. + +## Review Focus + +1. A `triggers` cell holding malformed JSON must count as "no triggers" in the hook and the UI, never a crash. Test in Task 2 (`test_triggered_candidates_skips_malformed_json`). +2. `path:` triggers written with forward slashes must match Windows backslash paths. Test in Task 1 (`test_path_trigger_normalises_separators`). +3. Tool input fields that are not strings (`command: None`, `content: {...}`) must not raise inside the matcher. Test in Task 1 (`test_non_string_inputs_never_match_or_raise`). +4. A prompt that is only whitespace, or that starts with `<` after leading whitespace, is non-human. Test in Task 5 (`test_nonhuman_prefix_detection` parametrised). +5. A candidate pool smaller than four memories must not have every token classed as ubiquitous by the 50% cap, otherwise nothing could ever qualify. Rule: the cap applies only when the pool has at least 4 memories. Test in Task 4 (`test_small_pool_keeps_all_tokens`). + +--- + +### Task 1: Trigger grammar and matcher + +**Confidence:** 95% + +**Files:** +- Create: `better_memory/services/triggers.py` +- Test: `tests/services/test_triggers.py` + +**Interfaces:** +- Produces: + - `VALID_PREFIXES: tuple[str, ...] = ("tool:", "skill:", "bash:", "path:", "write:")` + - `validate_triggers(raw: list[str]) -> list[str]` strips each entry, drops empties, raises `ValueError(f"invalid trigger {t!r}: expected one of tool:, skill:, bash:, path:, write:large")` for an unknown prefix or for `write:` with any operand other than `large`; returns the cleaned list (order kept, duplicates removed). + - `parse_triggers(raw: str | None) -> list[str]` decodes the JSON column; returns `[]` for `None`, empty string, non-JSON, or JSON that is not a list of strings. + - `match(triggers: list[str], tool_name: str, tool_input: dict, *, large_write_chars: int) -> str | None` returns the first trigger string that fires, else `None`. + - `_PATH_TOOLS = ("Write", "Edit", "Read")` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/services/test_triggers.py +import pytest +from better_memory.services.triggers import match, parse_triggers, validate_triggers + +LARGE = 8000 + +def test_tool_trigger_exact_name(): + assert match(["tool:WebFetch"], "WebFetch", {}, large_write_chars=LARGE) == "tool:WebFetch" + assert match(["tool:WebFetch"], "webfetch", {}, large_write_chars=LARGE) is None + +def test_skill_trigger_exact_and_prefix(): + assert match(["skill:superpowers:writing-plans"], "Skill", {"skill": "superpowers:writing-plans"}, large_write_chars=LARGE) + assert match(["skill:superpowers:*"], "Skill", {"skill": "superpowers:brainstorming"}, large_write_chars=LARGE) == "skill:superpowers:*" + assert match(["skill:superpowers:writing-plans"], "Skill", {"skill": "superpowers:brainstorming"}, large_write_chars=LARGE) is None + assert match(["skill:superpowers:*"], "Bash", {"command": "superpowers:x"}, large_write_chars=LARGE) is None + +def test_bash_trigger_substring_case_insensitive(): + assert match(["bash:<<"], "Bash", {"command": "cat > f <<'EOF'\nx\nEOF"}, large_write_chars=LARGE) == "bash:<<" + assert match(["bash:GIT PUSH"], "Bash", {"command": "git push origin"}, large_write_chars=LARGE) == "bash:GIT PUSH" + assert match(["bash:<<"], "Write", {"content": "<<"}, large_write_chars=LARGE) is None + +def test_path_trigger_normalises_separators(): + t = ["path:docs/superpowers/specs"] + assert match(t, "Write", {"file_path": r"C:\Users\x\docs\superpowers\specs\a.md"}, large_write_chars=LARGE) == t[0] + assert match(t, "Read", {"file_path": "/home/x/docs/superpowers/specs/a.md"}, large_write_chars=LARGE) == t[0] + assert match(t, "Bash", {"command": "docs/superpowers/specs"}, large_write_chars=LARGE) is None + +def test_write_large_threshold(): + assert match(["write:large"], "Write", {"content": "x" * 8001}, large_write_chars=LARGE) == "write:large" + assert match(["write:large"], "Write", {"content": "x" * 8000}, large_write_chars=LARGE) is None + assert match(["write:large"], "Edit", {"new_string": "x" * 9000}, large_write_chars=LARGE) is None + +def test_first_matching_trigger_wins(): + assert match(["tool:Agent", "bash:<<"], "Bash", {"command": "<<"}, large_write_chars=LARGE) == "bash:<<" + +def test_non_string_inputs_never_match_or_raise(): + assert match(["bash:<<"], "Bash", {"command": None}, large_write_chars=LARGE) is None + assert match(["write:large"], "Write", {"content": {"a": 1}}, large_write_chars=LARGE) is None + assert match(["path:x"], "Write", {}, large_write_chars=LARGE) is None + assert match(["skill:a"], "Skill", {"skill": 5}, large_write_chars=LARGE) is None + +def test_validate_accepts_grammar_and_cleans(): + assert validate_triggers([" tool:WebFetch ", "", "bash:<<", "tool:WebFetch"]) == ["tool:WebFetch", "bash:<<"] + assert validate_triggers(["write:large"]) == ["write:large"] + +@pytest.mark.parametrize("bad", ["webfetch", "tool:", "write:small", "agent:x", "skill:"]) +def test_validate_rejects_unknown(bad): + with pytest.raises(ValueError, match="invalid trigger"): + validate_triggers([bad]) + +@pytest.mark.parametrize("raw", [None, "", "not json", "{}", '["ok", 3]', "[1]"]) +def test_parse_triggers_tolerates_garbage(raw): + assert parse_triggers(raw) == [] or raw == '["ok", 3]' + +def test_parse_triggers_roundtrip(): + assert parse_triggers('["tool:WebFetch", "bash:<<"]') == ["tool:WebFetch", "bash:<<"] +``` + +Decision for `'["ok", 3]'`: a list with any non-string entry parses to `[]` (whole cell rejected). Replace the `or` clause in the test with that assertion. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/services/test_triggers.py -q` +Expected: FAIL with `ModuleNotFoundError: better_memory.services.triggers` + +- [ ] **Step 3: Implement `better_memory/services/triggers.py`** + +Pure module, imports only `json` and `re`. `match` lowercases both operand and haystack for `bash:` and `path:`; `path:` replaces `\\` with `/` in the file path before `in`; `tool:` compares exactly; `skill:` compares exactly, or by `startswith` when the operand ends with `*` (strip the `*`). Non-string input values are treated as absent. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/services/test_triggers.py -q` +Expected: all PASS + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/services/triggers.py tests/services/test_triggers.py +git commit -m "feat(triggers): pure trigger grammar, validator and matcher" +``` + +--- + +### Task 2: Storage: migration 0019, services, protocol, backends + +**Confidence:** 90% + +**Files:** +- Create: `better_memory/db/migrations/0019_memory_triggers.sql` +- Modify: `better_memory/services/semantic.py` (dataclass `SemanticMemory`, `create`, `get`, `list_for_project` SELECT lists, new `set_triggers`) +- Modify: `better_memory/services/reflection.py` (`_bucket_item`, `retrieve_reflections` SELECT; `ReflectionEditService.set_triggers`) +- Modify: `better_memory/storage/protocol.py`, `better_memory/storage/sqlite.py`, `better_memory/storage/agentcore.py` +- Test: `tests/db/test_migration_0019_triggers.py`, `tests/services/test_semantic.py`, `tests/services/test_reflection_writes.py`, `tests/storage/test_sqlite_backend.py`, `tests/storage/test_protocol.py`, `tests/storage/test_agentcore_unit.py` + +**Interfaces:** +- Consumes: `validate_triggers`, `parse_triggers` from Task 1. +- Produces: + - Migration adds `triggers TEXT` to `reflections` and `semantic_memories`, and inserts `rating_diagnostics` rows `contextual_suppressed_nonhuman`, `trigger_fired`, `trigger_injected` with value 0. + - `SemanticMemory.triggers: list[str] = field(default_factory=list)` (decoded). + - `SemanticMemoryService.create(..., triggers: list[str] | None = None) -> str` stores `json.dumps(validate_triggers(triggers))` or NULL. + - `SemanticMemoryService.set_triggers(*, id: str, triggers: list[str]) -> None` validates, stores JSON (NULL when the cleaned list is empty), bumps `updated_at`, raises `ValueError("Semantic memory not found: ")` when absent. + - `ReflectionEditService.set_triggers(*, reflection_id: str, triggers: list[str]) -> None` same contract, error `"Reflection not found: "`; no status restriction. + - `_bucket_item` dict gains `"triggers": list[str]`. + - Protocol: `supports_triggers` property; `set_triggers(*, kind: str, id: str, triggers: list[str]) -> None`; `triggered_candidates(*, project: str) -> list[dict]` where each dict has keys `kind`, `id`, `text`, `triggers`, `polarity` (None for semantic), `confidence` (None for semantic), `useful_count`, `updated_at`. Sqlite selects reflections `WHERE (project = ? OR scope = 'general') AND status IN ('pending_review','confirmed') AND triggers IS NOT NULL` and semantics `WHERE (project = ? OR scope = 'general') AND triggers IS NOT NULL`; `text` is `f"{title}: {use_cases} {' '.join(hints)}"` for reflections and `content` for semantics; a row whose `triggers` parses to `[]` is skipped. + - Agentcore: `supports_triggers -> False`; `set_triggers` raises `NotImplementedError("triggers are not supported on the agentcore backend")`; `triggered_candidates` returns `[]`. + +- [ ] **Step 1: Write failing tests** + +Migration test (`tests/db/test_migration_0019_triggers.py`), modelled on `tests/db/test_migration_0012_contextual_exposure.py`: after `apply_migrations`, `PRAGMA table_info` for both tables contains `triggers`; `rating_diagnostics` has the three new metrics at 0; applying twice is idempotent. + +Service tests (append to `tests/services/test_semantic.py` and `tests/services/test_reflection_writes.py`): +```python +def test_semantic_set_triggers_roundtrip(conn): + svc = SemanticMemoryService(conn) + sid = svc.create(content="rule", project="p", scope="general") + svc.set_triggers(id=sid, triggers=["tool:WebFetch", " bash:<< "]) + assert svc.get(id=sid).triggers == ["tool:WebFetch", "bash:<<"] + svc.set_triggers(id=sid, triggers=[]) + assert svc.get(id=sid).triggers == [] + assert conn.execute("select triggers from semantic_memories where id=?", (sid,)).fetchone()[0] is None + +def test_semantic_set_triggers_rejects_bad_grammar(conn): ... # ValueError "invalid trigger" +def test_semantic_set_triggers_missing_id(conn): ... # ValueError "Semantic memory not found" +def test_semantic_create_with_triggers(conn): ... # create(..., triggers=[...]) persists +def test_reflection_set_triggers_roundtrip(conn): ... # via ReflectionEditService; retrieve_reflections bucket item carries triggers +``` + +Backend tests (`tests/storage/test_sqlite_backend.py`): +```python +def test_triggered_candidates_returns_only_rows_with_triggers(backend, conn): ... +def test_triggered_candidates_includes_general_scope_from_other_project(backend, conn): ... +def test_triggered_candidates_skips_malformed_json(backend, conn): + conn.execute("update semantic_memories set triggers='{not json' where id=?", (sid,)) + assert all(c["id"] != sid for c in backend.triggered_candidates(project="p")) +def test_set_triggers_dispatches_by_kind(backend): ... +``` +Protocol test: extend `test_protocol_declares_all_required_methods` list with `set_triggers`, `triggered_candidates`; add `test_protocol_declares_supports_triggers_flag`. Agentcore unit test: `supports_triggers is False`, `set_triggers` raises `NotImplementedError`, `triggered_candidates` returns `[]`. + +- [ ] **Step 2: Run the new tests, confirm they fail** (`uv run pytest tests/db/test_migration_0019_triggers.py tests/services/test_semantic.py tests/services/test_reflection_writes.py tests/storage -q -k triggers`) + +- [ ] **Step 3: Implement** migration, service methods, protocol additions, sqlite and agentcore implementations as specified in Interfaces. Decode with `parse_triggers` everywhere a row is read; encode with `json.dumps` after `validate_triggers`. + +- [ ] **Step 4: Run** `uv run pytest tests/db tests/services/test_semantic.py tests/services/test_reflection_writes.py tests/services/test_reflection.py tests/storage tests/mcp -q` +Expected: all PASS (existing SELECT-shape tests in `tests/services/test_reflection_retrieve_fields.py` may need the new `triggers` key added to expected field sets). + +- [ ] **Step 5: Commit** `git commit -m "feat(storage): triggers column, services and backend surface"` + +--- + +### Task 3: Config: large-write threshold, deferred default, min-hits un-deprecated + +**Confidence:** 95% + +**Files:** +- Modify: `better_memory/config.py` (`Config` dataclass, `_resolve_inject_mode`, `get_config`) +- Test: `tests/test_config.py` + +**Interfaces:** +- Produces: `Config.trigger_large_write_chars: int` from `BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS` default 8000 (via `_resolve_nonneg_int`); `_resolve_inject_mode` returns `"deferred"` unless the env value is exactly `legacy` (case-insensitive, stripped); the deprecation NOTE on `context_min_hits` is replaced by "distinct-hit floor for the prompt gate in services/relevant.py". + +- [ ] **Step 1: Write failing tests** +```python +def test_trigger_large_write_default(monkeypatch): monkeypatch.delenv("BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS", raising=False); assert get_config().trigger_large_write_chars == 8000 +def test_trigger_large_write_env(monkeypatch): monkeypatch.setenv("BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS", "100"); assert get_config().trigger_large_write_chars == 100 +def test_inject_mode_defaults_to_deferred(monkeypatch): monkeypatch.delenv("BETTER_MEMORY_INJECT_MODE", raising=False); assert get_config().inject_mode == "deferred" +def test_inject_mode_legacy_opt_in(monkeypatch): monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", " Legacy "); assert get_config().inject_mode == "legacy" +``` +- [ ] **Step 2: Run, confirm fail.** `uv run pytest tests/test_config.py -q` +- [ ] **Step 3: Implement.** Search tests for any that asserted the old `legacy` default (`grep -rn "legacy" tests/test_config.py tests/services/test_session_bootstrap.py tests/hooks/test_session_bootstrap.py tests/conftest.py`) and update them to set the env explicitly. +- [ ] **Step 4: Run** `uv run pytest tests/test_config.py tests/services/test_session_bootstrap.py tests/hooks/test_session_bootstrap.py -q` → PASS +- [ ] **Step 5: Commit** `git commit -m "feat(config): trigger large-write threshold; deferred injection is the default"` + +--- + +### Task 4: Prompt gate: document-frequency filter and distinct-hit floor for both kinds + +**Confidence:** 90% + +**Files:** +- Modify: `better_memory/services/relevant.py` (`retrieve_relevant`, new helpers), `better_memory/hooks/contextual_inject.py` (pass `min_hits=cfg.context_min_hits`) +- Test: `tests/services/test_relevant.py` (rewrite `TestBM25Gate`, `TestSemantics`, `TestCapsAndDegradation`; keep `TestAgentCoreRelevanceGate` passing unchanged) + +**Interfaces:** +- Produces: + - `DF_CAP: float = 0.5`, `DF_MIN_POOL: int = 4` + - `_pool_tokens(texts: list[str]) -> list[set[str]]` and `_ubiquitous(token_sets: list[set[str]]) -> set[str]`: a token is ubiquitous when `len(token_sets) >= DF_MIN_POOL` and it appears in more than `DF_CAP * len(token_sets)` of them. + - `retrieve_relevant(backend, *, query, project, conn=None, max_items=3, include_neutral=False, now=None, min_hits: int = 2)`. + - Sqlite gate for both kinds: `keywords = extract_keywords(query) - ubiquitous`; `hits = count_keyword_hits(text, keywords)`; qualify iff `hits >= min_hits`. Ranking: RRF of Wilson rank and hit-count rank (descending hits; ties broken by BM25 rank from `_bm25_qualifiers` when available, else id). `RelevantMemory.hits` carries the distinct count. + - Agentcore branch (`agentcore_mode`) is unchanged, including its keyword fallback. + +- [ ] **Step 1: Rewrite the gate tests** +```python +class TestDistinctHitGate: + def test_single_shared_token_does_not_qualify(self, conn): ... # reflection titled "Use Playwright for Amazon pages", query "look at the amazon repo" -> [] + def test_two_distinct_hits_qualify(self, conn): ... # query "amazon playwright" -> served + def test_min_hits_env_floor_respected(self, conn): ... # min_hits=3 excludes a 2-hit memory + def test_ubiquitous_tokens_dropped(self, conn): ... # 5 memories all containing "claude code"; query "claude code research" -> none served unless another token hits + def test_small_pool_keeps_all_tokens(self, conn): ... # pool of 2 memories both containing "claude code"; query "claude code" -> both qualify + def test_semantic_and_reflection_share_gate(self, conn): ... # same query qualifies a semantic and a reflection with 2 hits each + def test_hits_rank_then_wilson(self, conn): ... # 3-hit memory outranks 2-hit memory with higher Wilson +``` +Delete `TestBM25Gate.test_bm25_match_qualifies` and `TestSemantics.test_semantic_fallback_keyword_when_no_embedder`; keep and re-target `test_no_evidence_no_injection`, `test_max_items_cap`, `test_no_conn_falls_back_to_keywords` (conn=None now follows the same gate), `test_blank_query_returns_empty`, `test_returns_relevantmemory`. + +- [ ] **Step 2: Run** `uv run pytest tests/services/test_relevant.py -q` → new tests FAIL +- [ ] **Step 3: Implement** in `relevant.py`; update the module docstring to describe the new gate; update `contextual_inject.py` to pass `min_hits=cfg.context_min_hits`. +- [ ] **Step 4: Run** `uv run pytest tests/services/test_relevant.py tests/services/test_relevant_format.py tests/hooks/test_contextual_inject.py -q` → PASS (hook tests that seeded a one-token match, e.g. `test_injection_renders_project_memory_block`, need two matching tokens in their seeded title/query) +- [ ] **Step 5: Commit** `git commit -m "feat(relevant): one distinct-hit gate with document-frequency filter for both memory kinds"` + +--- + +### Task 5: Prompt hook skips non-human prompts + +**Confidence:** 92% + +**Files:** +- Modify: `better_memory/hooks/contextual_inject.py` +- Test: `tests/hooks/test_contextual_inject.py` + +**Interfaces:** +- Produces: `NON_HUMAN_PREFIXES: tuple[str, ...] = ("<", "Another Claude session sent a message")`; `is_human_prompt(text: str) -> bool` (strip, non-empty, no prefix match). On UserPromptSubmit with a non-human prompt: bump `contextual_suppressed_nonhuman`, emit empty `additionalContext`, write no exposure, do not bump the turn counter. + +- [ ] **Step 1: Tests** +```python +@pytest.mark.parametrize("text,human", [("fix the bug", True), (" x", False), ("Another Claude session sent a message: hi", False), (" ", False), (" ls", False)]) +def test_nonhuman_prefix_detection(text, human): assert hook.is_human_prompt(text) is human +def test_peer_message_is_suppressed(bm_home, monkeypatch, capsys): ... # seeds a 2-hit memory; prompt is a peer message containing both tokens; asserts no block, no exposure row, counter == 1 +``` +- [ ] **Step 2: Run, FAIL.** - [ ] **Step 3: Implement.** - [ ] **Step 4: Run** `uv run pytest tests/hooks/test_contextual_inject.py -q` → PASS +- [ ] **Step 5: Commit** `git commit -m "feat(hook): skip non-human prompts in contextual injection"` + +--- + +### Task 6: Trigger channel on PreToolUse; latch removed; reason line + +**Confidence:** 90% + +**Files:** +- Modify: `better_memory/hooks/contextual_inject.py`, `better_memory/services/relevant.py` (`RelevantMemory.reason: str | None = None`; `format_relevant` adds ` Triggered by: {reason}` after the text line when set), `better_memory/services/context_seen.py` (delete `try_claim_pretool_fired`, `pretool_fired`, `mark_pretool_fired`, `_sentinel`), `better_memory/setup/manifest.py` (comment on the PreToolUse entry no longer mentions the latch) +- Test: `tests/hooks/test_contextual_inject.py`, `tests/services/test_context_seen.py`, `tests/services/test_relevant_format.py` + +**Interfaces:** +- Consumes: `match` (Task 1), `backend.triggered_candidates` (Task 2), `cfg.trigger_large_write_chars` (Task 3). +- Produces: `triggered_memories(backend, *, project: str, tool_name: str, tool_input: dict, large_write_chars: int, now=None) -> list[RelevantMemory]` in `relevant.py` (one entry per candidate whose triggers match, `reason` set, `hits=0`, `score` = Wilson); hook PreToolUse path: bump `contextual_fired_pretool` and `trigger_fired`, call `triggered_memories`, filter through `SeenStore`, cap to `cfg.context_max_items`, `record_exposures(source="trigger")`, `mark_seen`, bump `trigger_injected`; no latch. + +- [ ] **Step 1: Tests** +```python +def test_pretool_trigger_hit_injects_with_reason(bm_home, monkeypatch, capsys): ... # semantic with ["bash:<<"]; Bash heredoc payload -> block contains "Triggered by: bash:<<"; exposure source == "trigger" +def test_pretool_no_trigger_no_exposure(bm_home, monkeypatch, capsys): ... # same memory, tool Read -> empty context, zero exposure rows, trigger_fired == 1, trigger_injected == 0 +def test_pretool_fires_on_every_call(bm_home, monkeypatch, capsys): ... # replaces test_pretool_fires_once_per_session: two different triggered memories, two calls, both injected +def test_pretool_seen_store_dedups_trigger(bm_home, monkeypatch, capsys): ... # same trigger twice -> second call empty +def test_prompt_then_trigger_same_memory_once(bm_home, monkeypatch, capsys): ... # served by prompt channel first, trigger channel does not re-serve +def test_format_relevant_reason_line(): ... # in test_relevant_format.py +``` +Delete `test_pretool_fires_once_per_session`, `test_userprompt_unaffected_by_pretool_latch`, and the SeenStore latch tests in `tests/services/test_context_seen.py`. + +- [ ] **Step 2: Run, FAIL.** - [ ] **Step 3: Implement.** - [ ] **Step 4: Run** `uv run pytest tests/hooks tests/services/test_context_seen.py tests/services/test_relevant_format.py tests/setup -q` → PASS (golden-parity fixtures in `tests/setup/fixtures/` are unaffected: the manifest entry's matcher and command do not change) +- [ ] **Step 5: Commit** `git commit -m "feat(hook): trigger channel on every PreToolUse; drop the per-session latch"` + +--- + +### Task 7: Deferred bootstrap renders no memories + +**Confidence:** 92% + +**Files:** +- Modify: `better_memory/services/session_bootstrap.py` (deferred branch) +- Test: `tests/services/test_session_bootstrap.py`, `tests/hooks/test_session_bootstrap.py` + +**Interfaces:** +- Produces: deferred output is header, index line, `---`, footer; `BootstrapResult.semantic_count` and `reflections_counts` still report the pool sizes; no `session_memory_exposure` rows are written in deferred mode. + +- [ ] **Step 1: Tests** `test_deferred_renders_index_only` (no `### Semantic memories` heading even with general-scope rows present), `test_deferred_writes_no_exposures`, `test_legacy_still_dumps_general_semantics` (env `legacy`). +- [ ] **Step 2: Run, FAIL.** - [ ] **Step 3: Implement.** - [ ] **Step 4: Run** `uv run pytest tests/services/test_session_bootstrap.py tests/hooks/test_session_bootstrap.py tests/mcp/test_session_bootstrap_tool.py -q` → PASS +- [ ] **Step 5: Commit** `git commit -m "feat(bootstrap): deferred mode renders the index line only"` + +--- + +### Task 8: MCP: `triggers` on semantic observe and update + +**Confidence:** 92% + +**Files:** +- Modify: `better_memory/mcp/tools.py` (schemas), `better_memory/mcp/handlers/semantics.py` +- Test: `tests/mcp/test_semantic_tools.py` + +**Interfaces:** +- Consumes: `backend.semantic_observe(..., triggers=...)` (extend the protocol and both backends' `semantic_observe` with `triggers: list[str] | None = None`; agentcore raises `NotImplementedError` when non-empty), `backend.set_triggers` (Task 2). +- Produces: `memory.semantic_observe` schema property `triggers: {"type": "array", "items": {"type": "string"}, "description": "Tool-call triggers: tool:, skill:, bash:, path:, write:large"}`; `memory.semantic_update` gets the same optional property and `content` becomes optional (at least one of `content`, `triggers` must be present, else error text `"provide content and/or triggers"`). Handler returns the existing JSON shape plus `"triggers"` in `semantic_retrieve` rows. + +- [ ] **Step 1: Tests** `test_semantic_observe_with_triggers_persists`, `test_semantic_update_triggers_only`, `test_semantic_update_rejects_bad_trigger` (error payload contains `invalid trigger`), `test_semantic_retrieve_includes_triggers`, `test_schema_declares_triggers` (both tools). +- [ ] **Step 2: Run, FAIL.** - [ ] **Step 3: Implement.** - [ ] **Step 4: Run** `uv run pytest tests/mcp -q` → PASS +- [ ] **Step 5: Commit** `git commit -m "feat(mcp): triggers parameter on semantic observe and update"` + +--- + +### Task 9: Management UI: edit triggers; capability gate + +**Confidence:** 90% + +**Files:** +- Modify: `better_memory/ui/app.py` (`_inject_caps`, `reflection_edit_save`, `semantic_update`), `better_memory/ui/templates/fragments/reflection_edit_form.html`, `fragments/reflection_drawer.html`, `fragments/semantic_drawer.html`, `better_memory/ui/queries.py` (`reflection_detail` returns `triggers` decoded) +- Test: `tests/ui/test_reflections.py`, `tests/ui/test_semantic.py`, `tests/ui/test_nav_gating.py` + +**Interfaces:** +- Consumes: `backend.supports_triggers`, `backend.set_triggers` (Task 2). +- Produces: `caps.supports_triggers`; both forms post a `triggers` textarea (one per line); `reflection_edit_save` and `semantic_update` call `backend.set_triggers` after the text update and return 400 with the `ValueError` text on bad grammar; when `supports_triggers` is false the textarea is not rendered and a posted `triggers` field is ignored. Drawers show current triggers as chips. + +- [ ] **Step 1: Tests** `test_semantic_update_saves_triggers`, `test_semantic_update_bad_trigger_400`, `test_reflection_edit_saves_triggers`, `test_drawers_show_triggers`, `test_triggers_field_hidden_without_capability` (fake backend with `supports_triggers=False`, pattern from `tests/ui/test_nav_gating.py`). +- [ ] **Step 2: Run, FAIL.** - [ ] **Step 3: Implement.** - [ ] **Step 4: Run** `uv run pytest tests/ui -q -k "not browser"` → PASS +- [ ] **Step 5: Commit** `git commit -m "feat(ui): edit memory triggers in the drawers"` + +--- + +### Task 10: Diagnostics: useful rate by channel + +**Confidence:** 92% + +**Files:** +- Modify: `better_memory/ui/queries.py`, `better_memory/ui/app.py` (`diagnostics` route), `better_memory/ui/templates/diagnostics.html` +- Test: `tests/ui/test_diagnostics_ratings.py` + +**Interfaces:** +- Produces: `useful_rate_by_channel(conn, *, last_n_sessions: int | None) -> dict` with `{"rows": [{"source", "rated", "useful", "ignored", "misled", "rate"}...], "total": {...same keys...}, "rated_sessions": int, "useful_per_session": float}`; `rate` is `useful / rated` as a float (0.0 when rated is 0), `useful = cited + shaped`; an `all` row is appended as `total`. `last_n_sessions=None` means all time; otherwise restrict to the N most recent `session_id`s by `MAX(rated_at)` among sessions having any rated row. The route passes `useful_all` and `useful_recent` (N = 15); the template renders two tables under a section titled `Useful rate by channel` and a line `Useful per rated session: {{ '%.2f' % useful_all.useful_per_session }}`. + +- [ ] **Step 1: Tests** seed a ledger with three sessions across sources `bootstrap`, `contextual`, `trigger`, `retrieve`; assert per-source counts and rates, the `total`, `rated_sessions == 3`, `useful_per_session`, and that `last_n_sessions=2` drops the oldest session; route test asserts the section title and the `trigger` row render. +- [ ] **Step 2: Run, FAIL.** - [ ] **Step 3: Implement.** - [ ] **Step 4: Run** `uv run pytest tests/ui/test_diagnostics_ratings.py tests/ui/test_diagnostics.py -q` → PASS +- [ ] **Step 5: Commit** `git commit -m "feat(ui): useful rate by channel on the diagnostics page"` + +--- + +### Task 11: Documentation + +**Confidence:** 95% + +**Files:** +- Modify: `website/architecture.md` (Injection strategies: trigger channel, non-human skip, new gate, deferred default; Storage backends table: triggers row), `website/configuration.md` (`BETTER_MEMORY_CONTEXT_MIN_HITS` active again, `BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS`, `BETTER_MEMORY_INJECT_MODE` default), `website/mcp-tools.md` (`triggers`), `README.md` (How it works, step 3) + +- [ ] **Step 1: Edit the four files.** Each statement must match the implemented behaviour from Tasks 3 to 10; copy constant values from Global Constraints. +- [ ] **Step 2: Verify** `grep -n "latch\|once per session" website/architecture.md README.md` returns nothing describing the removed latch. +- [ ] **Step 3: Commit** `git commit -m "docs: just-in-time serving, triggers and the useful-rate panel"` + +--- + +### Task 12: Full verification and live deployment of triggers + +**Confidence:** 90% + +**Files:** +- No source changes. Live data: `~/.better-memory/memory.db` (owner's database). + +- [ ] **Step 1: Run the whole suite** `uv run pytest -q` → expected: all pass except `tests/setup/test_repo_hook.py::test_installs_in_worktree`, which fails on this machine for lack of a global git identity (pre-existing, unrelated). +- [ ] **Step 2: Lint and types** `uv run ruff check . && uv run pyright` → clean. +- [ ] **Step 3: Apply the deployment table** from the spec (section 2) to the live database with a one-off script using `SemanticMemoryService.set_triggers` and `ReflectionEditService.set_triggers` after `apply_migrations`; print each memory id with its stored triggers. +- [ ] **Step 4: Verify** `uv run python -m better_memory.hooks.contextual_inject` with a PreToolUse Bash heredoc payload for a fresh session id injects the heredoc pitfall with `Triggered by: bash:<<`; remove the temporary `state/context_seen_.json`. +- [ ] **Step 5: Record** a better-memory observation of the deployment (component `contextual_inject`, theme `decision`). diff --git a/docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md b/docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md new file mode 100644 index 0000000..d5ede77 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md @@ -0,0 +1,150 @@ +# Just-in-time serving: triggers, a tighter prompt gate, and a measured useful rate + +**Date:** 2026-10-05 +**Status:** design approved in chat 2026-10-05 (Approach A chosen over a measured-minimum and a rules-out-of-the-loop alternative; sections 1 and 2 approved as presented); written spec pending owner review before planning +**Predecessors:** 2026-06-14 contextual injection hook, 2026-07-11 attention-first injection, 2026-07-23 retrieval quality, 2026-07-23 deferred injection + +## Goal + +The owner's goal: better-memory should give Claude Code more targeted and appropriate memory, so that served memories are rated useful at least 33% of the time, and the absolute number of useful ratings should go up, not merely the ratio. + +Metric (owner's choice): per served item, across every channel that logs an exposure. `useful = cited + shaped`; `rate = useful / (useful + ignored + misled)` over rated exposures. Unrated exposures are excluded. + +Scope (owner's choice): serving only. Synthesis of the 53 un-synthesised episodes, and anything else on the supply side, is out of scope. + +## Measured baseline (live `memory.db`, 2026-10-05, before this session's ratings) + +| Channel | Useful | Ignored | Rate | +|---|---|---|---| +| bootstrap | 5 | 29 | 15% | +| contextual (prompt + first tool call) | 6 | 20 | 23% | +| retrieve (explicit `memory.retrieve`) | 1 | 2 | 33% | +| **all** | **12** | **51** | **19%** | + +No exposure has ever been rated `misled`. The store holds 4 reflections (all general-scope, all web-research or Windows pitfalls) and 6 semantic memories (all general-scope process rules written by the owner). 224 observations exist across four projects; only 6 of 59 episodes have been synthesised, so there are no project-specific reflections to serve. + +## What the replay spikes showed + +Four throwaway spikes replayed the real prompts and tool calls of the 11 fully rated sessions (from `~/.claude/projects/*/.jsonl`) against the memory pool as it existed at each moment, and scored each gate variant against the session's actual ratings. Scripts live in the session scratchpad and are not kept. + +| Gate variant (session-level union) | Served | Useful kept | Precision | +|---|---|---|---| +| Today: bootstrap dump + contextual OR-match | 63 | 12 of 12 | 19% | +| Typed prompts only, 2+ distinct non-ubiquitous keyword hits, no bootstrap dump | 21 | 9 of 12 | 43% | +| Same with 3+ hits | 10 | 5 of 12 | 50% | +| Tool-call keyword matching, any threshold | 25 to 61 | 7 to 12 | 20% to 28% | +| Activity-signature targeting learned from past ratings (leave one session out) | 22 | 7 of 12 | 32% | + +Three structural facts fall out of this: + +1. **Ratings are per (session, memory), and a session serves a memory at most once.** Over a 70 to 230 prompt session, every memory eventually matches some prompt or tool call, so gate strictness alone cannot move the ratio. Only the typed-prompt channel escapes, because typed prompts are few and short. +2. **Bootstrap is the largest loser.** It dumps all six general-scope rules into every session in every project; they account for 29 of the 51 ignored ratings and were useful only in spec and plan writing sessions. +3. **Timing is the untested lever.** Every useful rating came from a memory that matched the activity of the moment: planning rules while writing specs and plans, the heredoc pitfall while dispatching subagents or writing large files, the web pitfalls in a deep-research session. Today those memories arrive at session start. Serving them at the triggering tool call is the only route to *more* useful ratings rather than fewer serves. History cannot prove this because it has never been tried. + +Two further measurements shape the design: + +- **The prompt hook fires on peer-agent messages.** `contextual_fired_userprompt` is 571 across the transcripted sessions, which hold 261 typed prompts and 296 peer messages ("Another Claude session sent a message: ..."). Peer messages are long status reports with vocabulary that matches everything. +- **PreToolUse cost is interpreter start, not database work.** A full PreToolUse run of the hook takes 184 ms; a latched run takes 175 ms; interpreter plus imports alone take 168 ms. Removing the once-per-session latch adds nothing measurable. + +## Design + +### 1. Serving model + +Four channels, each with one job. The per-session `SeenStore` dedup applies across all of them, so a memory arrives once per session whichever channel claims it first. The per-firing item cap (`BETTER_MEMORY_CONTEXT_MAX_ITEMS`, default 3) is unchanged. + +**Bootstrap (SessionStart).** In deferred mode, `SessionBootstrapService.bootstrap` no longer renders general-scope semantic memories in full. It emits the header, the index line ("better-memory knows N reflections + M semantic memories ...") and the footer only, and records no bootstrap exposures. The code default for `BETTER_MEMORY_INJECT_MODE` becomes `deferred`, matching what `better-memory setup` already writes into `~/.claude/settings.json` (`manifest.MANAGED_ENV`). Legacy mode is untouched: its contract is byte-identical pre-deferred behaviour and it remains an explicit opt-in. + +**Typed-prompt channel (UserPromptSubmit).** Fires on every prompt as today, with two changes: + +- *Non-human prompts are skipped.* The hook payload carries no origin field, so the prompt text is checked against a short prefix list before any database work: text starting with `<` (command output, task notifications, system tags) or with `Another Claude session sent a message` is not a human prompt and produces no injection and no exposure. `contextual_suppressed_nonhuman` is added to `rating_diagnostics`. +- *One evidence gate for both memory kinds.* `retrieve_relevant` replaces the reflection BM25 "any single token" qualifier and the semantic 2-keyword fallback with a single rule: tokenise the prompt with `extract_keywords`, drop any token whose document frequency across the current candidate pool (reflections plus semantics for the project) exceeds `DF_CAP = 0.5`, count distinct whole-word hits per memory, and qualify a memory when `hits >= cfg.context_min_hits` (default 2; `BETTER_MEMORY_CONTEXT_MIN_HITS` is un-deprecated for this purpose). Ranking among qualifiers stays reciprocal rank fusion of the Wilson prior with a relevance rank; the relevance rank is now the distinct-hit count (ties by BM25 where the FTS table exists). Agentcore mode keeps its `relevance_ranks` gate unchanged. + +**Trigger channel (PreToolUse).** Fires on every tool call; the once-per-session latch (`SeenStore.try_claim_pretool_fired`) is removed. The hook loads the memories that carry triggers, matches each trigger against the tool name and input with the pure matcher in section 2, filters hits through `SeenStore`, caps them, records exposures with the new source value `trigger`, and renders them through `format_relevant` with one extra line per item naming the trigger that fired (for example `Triggered by: bash:<<`). A memory without triggers is never tool-triggered. `trigger_fired` and `trigger_injected` counters are added to `rating_diagnostics`; `contextual_fired_pretool` keeps counting firings for continuity. + +**Explicit retrieve (`memory.retrieve`, `memory.retrieve_observations`).** Unchanged. + +### 2. Triggers + +**Storage.** Migration `0019_memory_triggers.sql` adds a nullable `triggers TEXT` column (JSON array of strings, or NULL) to `reflections` and `semantic_memories`. No index is needed: the candidate query is `WHERE triggers IS NOT NULL` over a pool of at most a few hundred rows. + +**Grammar.** One trigger is one string. Five forms, validated on write; an unknown prefix is rejected with a `ValueError` naming the offending string. + +| Trigger | Matches when | +|---|---| +| `tool:` | `tool_name == Name` (exact, case-sensitive, e.g. `tool:WebFetch`, `tool:Agent`) | +| `skill:` | `tool_name == "Skill"` and `tool_input.skill == name`; a trailing `*` matches any skill with that prefix (e.g. `skill:superpowers:*`) | +| `bash:` | `tool_name == "Bash"` and `text in tool_input.command` | +| `path:` | `tool_name in {"Write", "Edit", "Read"}` and `text in tool_input.file_path` (separators normalised to `/`) | +| `write:large` | `tool_name == "Write"` and `len(tool_input.content) > cfg.trigger_large_write_chars` (default 8000, `BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS`) | + +**Matcher.** `better_memory/services/triggers.py`: `parse_triggers(raw) -> list[Trigger]`, `validate_triggers(list[str]) -> list[str]`, and `match(triggers, tool_name, tool_input) -> str | None` returning the first trigger string that fired (used as the display reason). Pure functions, no I/O, unit-tested in isolation. The `text` operands are matched case-insensitively. + +**Setting triggers.** + +- *Management UI.* The reflection edit drawer (`GET/POST /reflections//edit`) and the semantic update route (`POST /semantic//update`) gain a `triggers` textarea, one trigger per line. The drawers display current triggers. Both are gated by a new capability flag `caps.supports_triggers` (true on sqlite, false on agentcore) with the matching `abort(404)` guards, following the existing capability-gating pattern. +- *MCP.* `memory.semantic_observe` and `memory.semantic_update` accept an optional `triggers: list[str]`, validated with the same function, so Claude can attach triggers when it records a rule. Reflections get triggers through the UI only; filling them during synthesis is supply-side work and out of scope. +- *Storage protocol.* `StorageBackend` gains `supports_triggers` (property), `set_triggers(kind, id, triggers)` and `triggered_candidates(project) -> list[dict]` (id, kind, display text, triggers, rating counters). Sqlite implements them; agentcore reports `supports_triggers = False`, raises `NotImplementedError` from `set_triggers`, and returns `[]` from `triggered_candidates`, so the trigger channel is simply inactive there. + +**Deployment step for the ten existing memories** (set by the owner through the UI; values proposed from each memory's text and its useful-rating evidence lines): + +| Memory | Proposed triggers | +|---|---| +| Planning rule: confidence per task (`3c5faaa1`) | `skill:superpowers:writing-plans`, `path:docs/superpowers/plans` | +| Planning rule: sub-90% not acceptable (`6f890c7c`) | `skill:superpowers:writing-plans`, `path:docs/superpowers/plans` | +| No timescales (`ca5e13b1`) | `skill:superpowers:writing-plans`, `skill:superpowers:brainstorming`, `path:docs/superpowers/` | +| Design docs end with Assumptions (`f9216a31`) | `skill:superpowers:brainstorming`, `path:docs/superpowers/specs` | +| Spikes: just run them (`11efa502`) | `skill:superpowers:brainstorming`, `skill:superpowers:writing-plans` | +| Never encode input-specific rules (`63a4a799`) | `skill:superpowers:brainstorming`, `path:docs/superpowers/specs` | +| Write tool, not heredoc, for large files (`94113ed6`) | `bash:<<`, `write:large`, `tool:Agent` | +| Reddit unreachable (`98175e0f`) | `tool:WebFetch`, `tool:WebSearch`, `skill:anthropic-skills:deep-research` | +| WebFetch refuses retail domains and PDFs (`c6756ce6`) | `tool:WebFetch`, `skill:anthropic-skills:deep-research` | +| Playwright for Amazon and eBay (`561e74d4`) | `tool:WebFetch`, `skill:anthropic-skills:deep-research` | + +### 3. Measurement + +The Diagnostics page (`/diagnostics`) gains a **Useful rate by channel** panel, read from the local `memory.db` on both backends (it is session-operational state), with two windows: all time, and the last 15 rated sessions. Columns: source, rated, useful, ignored, misled, rate. A footer line reports useful ratings per rated session (today: 12 over 11 sessions, about 1.1), which is the guard against reaching the ratio by starving the channels. Query lives in `ui/queries.py`. + +**Success criteria**, judged over the first 15 rated sessions after deployment: + +- overall useful rate, all channels, at least 33%; +- useful ratings per rated session not below 1.1; +- the `trigger` channel's own rate reported, so that if the overall target is missed the next decision (retune triggers on specific memories, or add a track-record floor) is data-driven. The track-record floor is deliberately not part of this design. + +### 4. Error handling and backends + +- Both hooks keep the existing contract: never raise, always exit 0, failures recorded in `hook_errors`, no `additionalContext` on a failed turn. +- A malformed `triggers` JSON value on a row is treated as no triggers (logged once per hook run to `hook_errors`), never as a hook failure. +- Trigger validation errors surface as ordinary tool or form errors on write; nothing invalid reaches the column. +- Agentcore: trigger channel inactive, UI fields hidden, MCP `triggers` parameter rejected with a clear message when the backend does not support triggers. Nothing else in agentcore mode changes. + +### 5. Documentation + +In the same PR: `website/architecture.md` (injection strategies, trigger channel, protocol additions), `website/configuration.md` (`BETTER_MEMORY_CONTEXT_MIN_HITS` un-deprecated, `BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS`, default inject mode), `website/mcp-tools.md` (`triggers` parameter), and the README's hooks paragraph. + +## Testing + +- **Unit:** trigger grammar parsing, validation and matching for every form including the `skill:*` prefix, path separator normalisation and the large-write threshold; the prompt gate's document-frequency filter and distinct-hit floor on a fixture pool built from the ten live memories and the replayed prompts (useful and ignored cases from the spike as regression examples); the non-human prefix skip; deferred bootstrap rendering with no semantic section and no bootstrap exposures. +- **Hooks:** PreToolUse without the latch fires on the second and later calls; a trigger hit records a `trigger` exposure and renders the reason line; a session with no triggered memories costs no exposure rows; UserPromptSubmit on a peer message produces no injection and bumps `contextual_suppressed_nonhuman`. +- **Migration:** `0019` applies on an existing database and is idempotent. +- **Protocol and UI:** sqlite `set_triggers` / `triggered_candidates`; agentcore capability false with 404s on the trigger routes; both drawers round-trip triggers; MCP schema tests for the `triggers` parameter. +- **Diagnostics:** the useful-rate query on a seeded ledger for both windows and the per-session guard figure. +- **E2E:** the clean-slate smoke suite unchanged plus one scenario: set a trigger on a semantic memory, fire the hook with a matching Bash heredoc payload, assert injection and exposure source. + +## Out of scope + +Synthesis and the un-synthesised episode backlog; automatic trigger extraction at synthesis time; changes to `memory.retrieve` ranking; a Wilson track-record suppression floor; agentcore trigger storage; the `sqlite-vec` removal (#131). + +## Assumptions + +Each line: the assumption, its status, and what it costs if wrong. + +- **A1. Serving a memory at the triggering tool call raises how often it is applied and rated useful.** Unverified: history has no just-in-time serves to measure. Cost if wrong: the ratio stays near 20 to 25% (the trigger channel's replayed precision on session-level ratings); the fallback is the track-record floor kept out of this design. +- **A2. Session-end ratings are noisy enough that fewer than about 15 sessions cannot show a sustained rate.** Measured: sessions with near-identical skill usage rated the same planning rules useful in one and ignored in another. Cost if wrong (noise is lower): the window could be shorter; no design change. +- **A3. Removing the PreToolUse latch adds no measurable latency per tool call.** Measured: 184 ms full path against 175 ms latched, 168 ms of which is interpreter start and imports. Cost if wrong: a per-tool-call delay; mitigation is `BETTER_MEMORY_CONTEXT_INJECT_MODE=userprompt`. +- **A4. Gate constants: distinct-hit floor 2, document-frequency cap 50%.** Measured on 11 sessions and 12 useful ratings: floor 2 gives 43% precision at 75% recall, floor 3 gives 50% at 42%. This band rests on 12 positives and is thin; it is the constant most likely to need retuning once the measurement panel has more data. +- **A5. The prompt hook payload has no origin field, so non-human prompts must be detected by prefix.** Measured that the hook fires on peer messages (571 firings against 261 typed prompts plus 296 peer messages); the payload shape is as read in `contextual_inject._query_from`. Cost if wrong (a non-human prompt form without a known prefix): that form keeps leaking long texts into the gate; the diagnostics counter will show it. +- **A6. The ten existing memories map to accurate triggers by hand.** Verified by inspection of each memory's text and its useful-rating evidence lines (table in section 2). Cost if wrong for one memory: that memory under- or over-fires; fixable in the UI without code. +- **A7. Large-write threshold of 8,000 characters.** Unmeasured band, set below the roughly 30 KB heredoc failure the pitfall records and above typical source files. Cost if wrong: the heredoc pitfall over- or under-fires on Write; tunable by env without code. +- **A8. Agentcore users are unaffected by triggers being sqlite-only.** Verified that the protocol already uses capability flags for sqlite-only features and the UI gates on them. Cost if wrong: none for the owner, who runs sqlite. +- **A9. `better-memory setup` already installs deferred mode, so flipping the code default changes nothing for installed users.** Verified: `manifest.MANAGED_ENV = {"BETTER_MEMORY_INJECT_MODE": "deferred"}`. Cost if wrong: a user on a hand-edited settings file sees the dump disappear; the index line tells them how to retrieve. +- **A10. Per-prompt gating cannot by itself reach the target.** Measured in the replay (every prompt-level and tool-level keyword gate saturates at session level). This is why the design adds triggers rather than only tightening the gate. From 835ce78eb6b6cc95f1cb3c7ed932d0e6dfdd2d70 Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 22:54:38 +0100 Subject: [PATCH 02/16] feat(triggers): pure trigger grammar, validator and matcher Co-Authored-By: Claude Fable 5.1 --- better_memory/services/triggers.py | 123 +++++++++++++++++++++++++++++ tests/services/test_triggers.py | 90 +++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 better_memory/services/triggers.py create mode 100644 tests/services/test_triggers.py diff --git a/better_memory/services/triggers.py b/better_memory/services/triggers.py new file mode 100644 index 0000000..a933da6 --- /dev/null +++ b/better_memory/services/triggers.py @@ -0,0 +1,123 @@ +"""Tool-call triggers: the grammar, its validator, and the matcher. + +A trigger is one string that names a tool-call condition under which a +memory should be served just in time (design spec +2026-10-05-just-in-time-serving-design.md §2). Five forms: + +- ``tool:`` -- the tool name equals ``Name`` (exact, case-sensitive). +- ``skill:`` -- the Skill tool is invoked with that skill; a trailing + ``*`` matches any skill with that prefix. +- ``bash:`` -- a Bash command contains ``text`` (case-insensitive). +- ``path:`` -- a Write / Edit / Read file path contains ``text`` + (case-insensitive, separators normalised to ``/``). +- ``write:large`` -- a Write whose content exceeds ``large_write_chars``. + +Pure functions, no I/O. The matcher never raises on odd input shapes: a +non-string operand is treated as absent, an unknown stored trigger string is +skipped. +""" +from __future__ import annotations + +import json +from typing import Any + +VALID_PREFIXES: tuple[str, ...] = ("tool:", "skill:", "bash:", "path:", "write:") + +_PATH_TOOLS: tuple[str, ...] = ("Write", "Edit", "Read") + +_GRAMMAR_HELP = "expected one of tool:, skill:, bash:, path:, write:large" + + +def validate_triggers(raw: list[str]) -> list[str]: + """Strip, drop empties, de-duplicate (order kept) and check the grammar. + + Raises ``ValueError`` naming the first offending string. + """ + out: list[str] = [] + for item in raw or []: + t = (item or "").strip() if isinstance(item, str) else "" + if not t: + continue + prefix, _, operand = t.partition(":") + prefix = prefix + ":" + bad = prefix not in VALID_PREFIXES or not operand + if prefix == "write:" and operand != "large": + bad = True + if bad: + raise ValueError(f"invalid trigger {t!r}: {_GRAMMAR_HELP}") + if t not in out: + out.append(t) + return out + + +def parse_triggers(raw: str | None) -> list[str]: + """Decode the JSON column. Anything that is not a JSON list of strings + (``None``, empty, malformed, wrong shape) decodes to ``[]``.""" + if not raw: + return [] + try: + data = json.loads(raw) + except (TypeError, ValueError): + return [] + if not isinstance(data, list) or not all(isinstance(x, str) for x in data): + return [] + return list(data) + + +def _str(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _fires(trigger: str, tool_name: str, tool_input: dict, large_write_chars: int) -> bool: + prefix, _, operand = trigger.partition(":") + if not operand: + return False + if prefix == "tool": + return tool_name == operand + if prefix == "skill": + if tool_name != "Skill": + return False + skill = _str(tool_input.get("skill")) + if skill is None: + return False + if operand.endswith("*"): + return skill.startswith(operand[:-1]) + return skill == operand + if prefix == "bash": + if tool_name != "Bash": + return False + command = _str(tool_input.get("command")) + return command is not None and operand.lower() in command.lower() + if prefix == "path": + if tool_name not in _PATH_TOOLS: + return False + path = _str(tool_input.get("file_path")) + if path is None: + return False + return operand.lower().replace("\\", "/") in path.lower().replace("\\", "/") + if prefix == "write": + if tool_name != "Write" or operand != "large": + return False + content = _str(tool_input.get("content")) + return content is not None and len(content) > large_write_chars + return False + + +def match( + triggers: list[str], + tool_name: str, + tool_input: dict | None, + *, + large_write_chars: int, +) -> str | None: + """Return the first trigger string that fires for this tool call, else None.""" + inp = tool_input if isinstance(tool_input, dict) else {} + for trigger in triggers or []: + if not isinstance(trigger, str): + continue + try: + if _fires(trigger, tool_name or "", inp, large_write_chars): + return trigger + except Exception: # noqa: BLE001 - a corrupt trigger must never break serving + continue + return None diff --git a/tests/services/test_triggers.py b/tests/services/test_triggers.py new file mode 100644 index 0000000..d25678c --- /dev/null +++ b/tests/services/test_triggers.py @@ -0,0 +1,90 @@ +"""Tests for the pure trigger grammar, validator and matcher.""" +from __future__ import annotations + +import pytest + +from better_memory.services.triggers import match, parse_triggers, validate_triggers + +LARGE = 8000 + + +def _m(triggers, tool, inp): + return match(triggers, tool, inp, large_write_chars=LARGE) + + +def test_tool_trigger_exact_name(): + assert _m(["tool:WebFetch"], "WebFetch", {}) == "tool:WebFetch" + assert _m(["tool:WebFetch"], "webfetch", {}) is None + + +def test_skill_trigger_exact_and_prefix(): + assert _m(["skill:superpowers:writing-plans"], "Skill", + {"skill": "superpowers:writing-plans"}) == "skill:superpowers:writing-plans" + assert _m(["skill:superpowers:*"], "Skill", + {"skill": "superpowers:brainstorming"}) == "skill:superpowers:*" + assert _m(["skill:superpowers:writing-plans"], "Skill", + {"skill": "superpowers:brainstorming"}) is None + assert _m(["skill:superpowers:*"], "Bash", {"command": "superpowers:x"}) is None + + +def test_bash_trigger_substring_case_insensitive(): + assert _m(["bash:<<"], "Bash", {"command": "cat > f <<'EOF'\nx\nEOF"}) == "bash:<<" + assert _m(["bash:GIT PUSH"], "Bash", {"command": "git push origin"}) == "bash:GIT PUSH" + assert _m(["bash:<<"], "Write", {"content": "<<"}) is None + + +def test_path_trigger_normalises_separators(): + t = ["path:docs/superpowers/specs"] + assert _m(t, "Write", {"file_path": r"C:\Users\x\docs\superpowers\specs\a.md"}) == t[0] + assert _m(t, "Read", {"file_path": "/home/x/docs/superpowers/specs/a.md"}) == t[0] + assert _m(t, "Edit", {"file_path": "/home/x/DOCS/Superpowers/specs/a.md"}) == t[0] + assert _m(t, "Bash", {"command": "docs/superpowers/specs"}) is None + + +def test_write_large_threshold(): + assert _m(["write:large"], "Write", {"content": "x" * 8001}) == "write:large" + assert _m(["write:large"], "Write", {"content": "x" * 8000}) is None + assert _m(["write:large"], "Edit", {"new_string": "x" * 9000}) is None + + +def test_first_matching_trigger_wins(): + assert _m(["tool:Agent", "bash:<<"], "Bash", {"command": "<<"}) == "bash:<<" + assert _m(["bash:<<", "tool:Bash"], "Bash", {"command": "ls"}) == "tool:Bash" + + +def test_non_string_inputs_never_match_or_raise(): + assert _m(["bash:<<"], "Bash", {"command": None}) is None + assert _m(["write:large"], "Write", {"content": {"a": 1}}) is None + assert _m(["path:x"], "Write", {}) is None + assert _m(["skill:a"], "Skill", {"skill": 5}) is None + assert _m(["bash:<<"], "Bash", None) is None # type: ignore[arg-type] + + +def test_unknown_trigger_strings_are_ignored_by_match(): + # A corrupt stored value must never fire or raise at serve time. + assert _m(["bogus:x", "tool:Bash"], "Bash", {}) == "tool:Bash" + + +def test_validate_accepts_grammar_and_cleans(): + assert validate_triggers([" tool:WebFetch ", "", "bash:<<", "tool:WebFetch"]) == [ + "tool:WebFetch", "bash:<<", + ] + assert validate_triggers(["write:large"]) == ["write:large"] + assert validate_triggers([]) == [] + + +@pytest.mark.parametrize( + "bad", ["webfetch", "tool:", "write:small", "agent:x", "skill:", "bash:", "path:"], +) +def test_validate_rejects_unknown(bad): + with pytest.raises(ValueError, match="invalid trigger"): + validate_triggers([bad]) + + +@pytest.mark.parametrize("raw", [None, "", "not json", "{}", '["ok", 3]', "[1]", '"str"']) +def test_parse_triggers_tolerates_garbage(raw): + assert parse_triggers(raw) == [] + + +def test_parse_triggers_roundtrip(): + assert parse_triggers('["tool:WebFetch", "bash:<<"]') == ["tool:WebFetch", "bash:<<"] From 0471ca91283d018456e564361943cfa407b6aa39 Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:01:24 +0100 Subject: [PATCH 03/16] feat(storage): triggers column, services and backend surface Co-Authored-By: Claude Fable 5.1 --- .../db/migrations/0019_memory_triggers.sql | 14 ++++ better_memory/services/reflection.py | 21 +++++- better_memory/services/semantic.py | 41 +++++++++-- better_memory/storage/agentcore.py | 19 ++++- better_memory/storage/protocol.py | 33 ++++++++- better_memory/storage/sqlite.py | 70 +++++++++++++++++++ tests/db/test_migration_0019_triggers.py | 52 ++++++++++++++ tests/services/test_reflection_writes.py | 32 +++++++++ tests/services/test_semantic.py | 37 ++++++++++ tests/storage/test_agentcore_unit.py | 7 ++ tests/storage/test_protocol.py | 7 ++ tests/storage/test_sqlite_backend.py | 54 ++++++++++++++ 12 files changed, 377 insertions(+), 10 deletions(-) create mode 100644 better_memory/db/migrations/0019_memory_triggers.sql create mode 100644 tests/db/test_migration_0019_triggers.py diff --git a/better_memory/db/migrations/0019_memory_triggers.sql b/better_memory/db/migrations/0019_memory_triggers.sql new file mode 100644 index 0000000..4f8d326 --- /dev/null +++ b/better_memory/db/migrations/0019_memory_triggers.sql @@ -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); diff --git a/better_memory/services/reflection.py b/better_memory/services/reflection.py index 14e579b..87ebb5e 100644 --- a/better_memory/services/reflection.py +++ b/better_memory/services/reflection.py @@ -40,6 +40,7 @@ from better_memory._common import default_clock, env_session_id from better_memory.search.query import sanitize_fts5_query from better_memory.services.scoring import wilson_lower_bound +from better_memory.services.triggers import parse_triggers, validate_triggers # Dropped from relevance queries. These survive `sanitize_fts5_query` and the # >2-char filter, but appear in so many reflections that OR-matching on them @@ -1342,6 +1343,7 @@ def _bucket_item(self, r) -> dict: "times_overlooked": r["times_overlooked"], "times_ignored": r["times_ignored"], "updated_at": r["updated_at"], + "triggers": parse_triggers(r["triggers"]), } def retrieve_reflections( @@ -1409,7 +1411,7 @@ def retrieve_reflections( SELECT id, title, phase, polarity, use_cases, hints, confidence, tech, evidence_count, useful_count, times_misled, times_overlooked, times_ignored, - updated_at + updated_at, triggers FROM reflections WHERE {where} """, @@ -1637,6 +1639,23 @@ def update_text( ) self._conn.commit() + def set_triggers(self, *, reflection_id: str, triggers: list[str]) -> None: + """Replace a reflection's tool-call triggers (any status). + + Validates the grammar; an empty cleaned list stores NULL. Bumps + updated_at. Raises ValueError on bad grammar or missing id. + """ + cleaned = validate_triggers(triggers or []) + now = self._clock().isoformat() + cur = self._conn.execute( + "UPDATE reflections SET triggers = ?, updated_at = ? WHERE id = ?", + (json.dumps(cleaned) if cleaned else None, now, reflection_id), + ) + if cur.rowcount == 0: + self._conn.rollback() + raise ValueError(f"Reflection not found: {reflection_id}") + self._conn.commit() + def promote_to_general(self, *, reflection_id: str) -> None: """project → general; idempotent on already-general; raise on retired/superseded. diff --git a/better_memory/services/semantic.py b/better_memory/services/semantic.py index 178f5e9..0eb4449 100644 --- a/better_memory/services/semantic.py +++ b/better_memory/services/semantic.py @@ -9,14 +9,16 @@ from __future__ import annotations +import json import sqlite3 from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from uuid import uuid4 from better_memory._common import default_clock, env_session_id from better_memory.services.scoring import wilson_lower_bound +from better_memory.services.triggers import parse_triggers, validate_triggers @dataclass(frozen=True) @@ -37,6 +39,7 @@ class SemanticMemory: last_overlooked_at: str | None = None times_ignored: int = 0 last_ignored_at: str | None = None + triggers: list[str] = field(default_factory=list) _VALID_SCOPES = ("project", "general") @@ -59,7 +62,8 @@ def __init__( self._clock: Callable[[], datetime] = clock or default_clock def create( - self, *, content: str, project: str, scope: str = "project" + self, *, content: str, project: str, scope: str = "project", + triggers: list[str] | None = None, ) -> str: if scope not in _VALID_SCOPES: raise ValueError( @@ -67,15 +71,17 @@ def create( ) if not content.strip(): raise ValueError("content must not be empty") + cleaned = validate_triggers(triggers or []) memory_id = uuid4().hex now = self._clock().isoformat() self._conn.execute( """ INSERT INTO semantic_memories - (id, content, project, scope, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) + (id, content, project, scope, created_at, updated_at, triggers) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (memory_id, content, project, scope, now, now), + (memory_id, content, project, scope, now, now, + json.dumps(cleaned) if cleaned else None), ) self._conn.commit() return memory_id @@ -119,6 +125,25 @@ def set_scope(self, *, id: str, scope: str) -> None: raise ValueError(f"semantic memory not found: {id}") self._conn.commit() + def set_triggers(self, *, id: str, triggers: list[str]) -> None: + """Replace a semantic memory's tool-call triggers. + + Validates the grammar (``services.triggers.validate_triggers``); + an empty cleaned list stores NULL. Bumps updated_at. Raises + ValueError on bad grammar or missing id. + """ + cleaned = validate_triggers(triggers or []) + now = self._clock().isoformat() + cur = self._conn.execute( + "UPDATE semantic_memories SET triggers = ?, updated_at = ? " + "WHERE id = ?", + (json.dumps(cleaned) if cleaned else None, now, id), + ) + if cur.rowcount == 0: + self._conn.rollback() + raise ValueError(f"semantic memory not found: {id}") + self._conn.commit() + def create_from_observation( self, *, observation_id: str, scope: str = "project" ) -> str: @@ -194,7 +219,7 @@ def get(self, *, id: str) -> SemanticMemory | None: row = self._conn.execute( "SELECT id, content, project, scope, created_at, updated_at, " "useful_count, last_useful_at, times_misled, last_misled_at, " - "times_overlooked, last_overlooked_at, times_ignored, last_ignored_at " + "times_overlooked, last_overlooked_at, times_ignored, last_ignored_at, triggers " "FROM semantic_memories WHERE id = ?", (id,), ).fetchone() @@ -208,6 +233,7 @@ def get(self, *, id: str) -> SemanticMemory | None: times_overlooked=row["times_overlooked"] or 0, last_overlooked_at=row["last_overlooked_at"], times_ignored=row["times_ignored"] or 0, last_ignored_at=row["last_ignored_at"], + triggers=parse_triggers(row["triggers"]), ) def list_for_project( @@ -262,7 +288,7 @@ def list_for_project( "SELECT id, content, project, scope, created_at, updated_at, " "useful_count, last_useful_at, times_misled, last_misled_at, " "times_overlooked, last_overlooked_at, " - "times_ignored, last_ignored_at " + "times_ignored, last_ignored_at, triggers " "FROM semantic_memories " f"WHERE {' AND '.join(where_clauses)} " "ORDER BY created_at DESC" @@ -281,6 +307,7 @@ def list_for_project( last_overlooked_at=r["last_overlooked_at"], times_ignored=r["times_ignored"] or 0, last_ignored_at=r["last_ignored_at"], + triggers=parse_triggers(r["triggers"]), ) for r in rows ] diff --git a/better_memory/storage/agentcore.py b/better_memory/storage/agentcore.py index c0362f5..df07118 100644 --- a/better_memory/storage/agentcore.py +++ b/better_memory/storage/agentcore.py @@ -176,6 +176,18 @@ def supports_reflection_review(self) -> bool: def supports_reflection_text_edit(self) -> bool: return False + @property + def supports_triggers(self) -> bool: + return False + + # ----- Triggers (unsupported: no trigger storage on AgentCore records) ----- + + def set_triggers(self, *, kind: str, id: str, triggers: list[str]) -> None: + raise NotImplementedError("triggers are not supported on the agentcore backend") + + def triggered_candidates(self, *, project: str) -> list[dict[str, Any]]: + return [] + # ----- Session-id per-operation re-resolution ----- def _require_session_id(self, operation: str) -> str: @@ -1419,10 +1431,15 @@ def semantic_observe( content: str, project: str | None = None, scope: str = "project", + triggers: list[str] | None = None, ) -> str: """Create a semantic memory record. Bypasses LLM extraction — the content is the preference text directly, written under the - userPreferenceMemoryStrategy so AWS applies its schema validation.""" + userPreferenceMemoryStrategy so AWS applies its schema validation. + ``triggers`` is rejected when non-empty: AgentCore records have no + trigger storage (``supports_triggers`` is False).""" + if triggers: + raise NotImplementedError("triggers are not supported on the agentcore backend") actor_id = resolve_actor_id(project or self._project) if scope == "general": namespace = resolve_namespace("general", "semantic") diff --git a/better_memory/storage/protocol.py b/better_memory/storage/protocol.py index 3dfdfba..6d3e0f6 100644 --- a/better_memory/storage/protocol.py +++ b/better_memory/storage/protocol.py @@ -98,6 +98,14 @@ def supports_reflection_review(self) -> bool: with NO pending_review state -- reflections are born active.""" ... + @property + def supports_triggers(self) -> bool: + """True when memories carry tool-call triggers that the PreToolUse + trigger channel can serve and the UI can edit (sqlite). False in + agentcore mode, where no trigger storage exists; the trigger + channel is then inactive and the UI hides the trigger editors.""" + ... + @property def supports_reflection_text_edit(self) -> bool: """True when a reflection's use_cases / hints text is user-editable in @@ -232,8 +240,12 @@ def semantic_observe( content: str, project: str | None = None, scope: str = "project", + triggers: list[str] | None = None, ) -> str: - """Create a semantic memory. Returns its id.""" + """Create a semantic memory. Returns its id. ``triggers`` is the + optional tool-call trigger list (services.triggers grammar); + backends without ``supports_triggers`` raise NotImplementedError + when it is non-empty.""" ... def semantic_list( @@ -269,6 +281,25 @@ def semantic_get(self, *, id: str) -> Any | None: ResourceNotFoundException as the None case.""" ... + # ----- Triggers ----- + + def set_triggers(self, *, kind: str, id: str, triggers: list[str]) -> None: + """Replace the tool-call triggers of a reflection or semantic memory. + ``kind`` is 'reflection' | 'semantic'. Raises ValueError on bad + grammar, unknown kind or missing id; NotImplementedError when the + backend does not support triggers.""" + ... + + def triggered_candidates(self, *, project: str) -> list[dict[str, Any]]: + """Memories visible to ``project`` (project rows plus general scope) + that carry at least one trigger, as dicts with keys ``kind``, ``id``, + ``text``, ``triggers``, ``polarity`` (None for semantic), + ``confidence`` (None for semantic), ``useful_count``, + ``times_overlooked``, ``times_ignored``, ``updated_at``. Retired and + superseded reflections are excluded; a row whose stored triggers do + not parse is skipped. Backends without trigger storage return [].""" + ... + # ----- Episodes ----- def open_background_episode( diff --git a/better_memory/storage/sqlite.py b/better_memory/storage/sqlite.py index 3925f14..1004ab4 100644 --- a/better_memory/storage/sqlite.py +++ b/better_memory/storage/sqlite.py @@ -19,6 +19,7 @@ from __future__ import annotations +import json import sqlite3 from collections.abc import Sequence from typing import Any @@ -32,6 +33,7 @@ ) from better_memory.services.semantic import SemanticMemoryService from better_memory.services.session_bootstrap import SessionBootstrapService +from better_memory.services.triggers import parse_triggers from better_memory.storage.protocol import Outcome, UseOutcome @@ -88,6 +90,10 @@ def supports_retention_runs(self) -> bool: def supports_reflection_review(self) -> bool: return True + @property + def supports_triggers(self) -> bool: + return True + @property def supports_reflection_text_edit(self) -> bool: return True @@ -177,11 +183,13 @@ def semantic_observe( content: str, project: str | None = None, scope: str = "project", + triggers: list[str] | None = None, ) -> str: return self._semantic.create( content=content, project=project or self._project, scope=scope, + triggers=triggers, ) def semantic_list( @@ -211,6 +219,68 @@ def semantic_delete(self, *, id: str) -> None: def semantic_get(self, *, id: str) -> Any | None: return self._semantic.get(id=id) + # ----- Triggers ----- + + def set_triggers(self, *, kind: str, id: str, triggers: list[str]) -> None: + if kind == "semantic": + self._semantic.set_triggers(id=id, triggers=triggers) + elif kind == "reflection": + self._reflection.set_triggers(reflection_id=id, triggers=triggers) + else: + raise ValueError( + f"unknown memory kind {kind!r}; expected 'reflection' or 'semantic'" + ) + + def triggered_candidates(self, *, project: str) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + refl_rows = self._conn.execute( + "SELECT id, title, use_cases, hints, polarity, confidence, useful_count, " + "times_overlooked, times_ignored, updated_at, triggers " + "FROM reflections " + "WHERE (project = ? OR scope = 'general') " + " AND status IN ('pending_review', 'confirmed') " + " AND triggers IS NOT NULL", + (project,), + ).fetchall() + for r in refl_rows: + triggers = parse_triggers(r["triggers"]) + if not triggers: + continue + try: + hints = json.loads(r["hints"] or "[]") + except (TypeError, ValueError): + hints = [] + body = " ".join([str(r["use_cases"] or "")] + [str(h) for h in hints]) + out.append({ + "kind": "reflection", "id": r["id"], + "text": f"{r['title']}: {body}".strip(": "), + "triggers": triggers, "polarity": r["polarity"], + "confidence": r["confidence"], + "useful_count": int(r["useful_count"] or 0), + "times_overlooked": int(r["times_overlooked"] or 0), + "times_ignored": int(r["times_ignored"] or 0), + "updated_at": r["updated_at"], + }) + sem_rows = self._conn.execute( + "SELECT id, content, useful_count, times_overlooked, times_ignored, " + "updated_at, triggers FROM semantic_memories " + "WHERE (project = ? OR scope = 'general') AND triggers IS NOT NULL", + (project,), + ).fetchall() + for r in sem_rows: + triggers = parse_triggers(r["triggers"]) + if not triggers: + continue + out.append({ + "kind": "semantic", "id": r["id"], "text": r["content"], + "triggers": triggers, "polarity": None, "confidence": None, + "useful_count": int(r["useful_count"] or 0), + "times_overlooked": int(r["times_overlooked"] or 0), + "times_ignored": int(r["times_ignored"] or 0), + "updated_at": r["updated_at"], + }) + return out + # ----- Episodes ----- def open_background_episode( diff --git a/tests/db/test_migration_0019_triggers.py b/tests/db/test_migration_0019_triggers.py new file mode 100644 index 0000000..d8d5c0d --- /dev/null +++ b/tests/db/test_migration_0019_triggers.py @@ -0,0 +1,52 @@ +"""Migration 0019: nullable ``triggers`` JSON column on both memory tables; +seed the trigger-channel diagnostics counters.""" +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 + + +@pytest.fixture +def conn(tmp_memory_db: Path): + c = connect(tmp_memory_db) + apply_migrations(c) + try: + yield c + finally: + c.close() + + +def _columns(conn, table: str) -> set[str]: + return {r[1] for r in conn.execute(f"PRAGMA table_info({table})")} + + +def test_triggers_column_on_both_tables(conn): + assert "triggers" in _columns(conn, "reflections") + assert "triggers" in _columns(conn, "semantic_memories") + + +def test_triggers_column_is_nullable_default_null(conn): + conn.execute( + "INSERT INTO semantic_memories (id, content, project, scope, created_at, updated_at) " + "VALUES ('s1', 'c', 'p', 'project', '2026-01-01', '2026-01-01')" + ) + row = conn.execute("SELECT triggers FROM semantic_memories WHERE id='s1'").fetchone() + assert row[0] is None + + +def test_seeds_trigger_diagnostics(conn): + rows = dict(conn.execute("SELECT metric, value FROM rating_diagnostics").fetchall()) + for metric in ("contextual_suppressed_nonhuman", "trigger_fired", "trigger_injected"): + assert rows[metric] == 0 + + +def test_idempotent(conn): + apply_migrations(conn) + n = conn.execute( + "SELECT COUNT(*) FROM rating_diagnostics WHERE metric='trigger_fired'" + ).fetchone()[0] + assert n == 1 diff --git a/tests/services/test_reflection_writes.py b/tests/services/test_reflection_writes.py index 370cda6..50e6149 100644 --- a/tests/services/test_reflection_writes.py +++ b/tests/services/test_reflection_writes.py @@ -286,3 +286,35 @@ def test_raises_when_superseded(self, conn, fixed_clock): svc = ReflectionService(conn, clock=fixed_clock) with pytest.raises(ValueError, match="Cannot promote reflection in status 'superseded'"): svc.promote_to_general(reflection_id="r1") + + +class TestSetTriggers: + def test_set_triggers_roundtrip_and_bucket_item(self, conn, fixed_clock): + from better_memory.services.reflection import ( + ReflectionService, + ReflectionSynthesisService, + ) + rid = "trig-1" + conn.execute( + "INSERT INTO reflections (id, title, project, phase, polarity, use_cases, " + "hints, confidence, status, created_at, updated_at) VALUES " + "(?, 't', 'proj-a', 'general', 'do', 'uc', '[\"h\"]', 0.7, 'confirmed', " + "'2026-04-25T00:00:00+00:00', '2026-04-25T00:00:00+00:00')", + (rid,), + ) + conn.commit() + ReflectionService(conn, clock=fixed_clock).set_triggers( + reflection_id=rid, triggers=["bash:<<", "write:large"], + ) + buckets = ReflectionSynthesisService(conn).retrieve_reflections( + project="proj-a", track_exposure=False, + ) + item = next(r for b in buckets.values() for r in b if r["id"] == rid) + assert item["triggers"] == ["bash:<<", "write:large"] + + def test_set_triggers_missing_reflection(self, conn, fixed_clock): + from better_memory.services.reflection import ReflectionService + with pytest.raises(ValueError, match="Reflection not found"): + ReflectionService(conn, clock=fixed_clock).set_triggers( + reflection_id="nope", triggers=["tool:X"], + ) diff --git a/tests/services/test_semantic.py b/tests/services/test_semantic.py index 3664a45..ba7561f 100644 --- a/tests/services/test_semantic.py +++ b/tests/services/test_semantic.py @@ -605,3 +605,40 @@ def test_semantic_service_get_returns_model_or_none(conn): assert isinstance(got, SemanticMemory) assert got.id == mid and got.content == "a rule" and got.scope == "general" assert svc.get(id="missing") is None + + +class TestTriggers: + def test_set_triggers_roundtrip(self, conn): + from better_memory.services.semantic import SemanticMemoryService + svc = SemanticMemoryService(conn) + sid = svc.create(content="rule", project="p", scope="general") + svc.set_triggers(id=sid, triggers=["tool:WebFetch", " bash:<< "]) + assert svc.get(id=sid).triggers == ["tool:WebFetch", "bash:<<"] + assert svc.list_for_project(project="p", track_exposure=False)[0].triggers == [ + "tool:WebFetch", "bash:<<", + ] + svc.set_triggers(id=sid, triggers=[]) + assert svc.get(id=sid).triggers == [] + assert conn.execute( + "select triggers from semantic_memories where id=?", (sid,) + ).fetchone()[0] is None + + def test_set_triggers_rejects_bad_grammar(self, conn): + from better_memory.services.semantic import SemanticMemoryService + svc = SemanticMemoryService(conn) + sid = svc.create(content="rule", project="p") + with pytest.raises(ValueError, match="invalid trigger"): + svc.set_triggers(id=sid, triggers=["nope"]) + + def test_set_triggers_missing_id(self, conn): + from better_memory.services.semantic import SemanticMemoryService + with pytest.raises(ValueError, match="semantic memory not found"): + SemanticMemoryService(conn).set_triggers(id="missing", triggers=["tool:X"]) + + def test_create_with_triggers(self, conn): + from better_memory.services.semantic import SemanticMemoryService + svc = SemanticMemoryService(conn) + sid = svc.create(content="rule", project="p", triggers=["skill:superpowers:*"]) + assert svc.get(id=sid).triggers == ["skill:superpowers:*"] + sid2 = svc.create(content="rule2", project="p") + assert svc.get(id=sid2).triggers == [] diff --git a/tests/storage/test_agentcore_unit.py b/tests/storage/test_agentcore_unit.py index 1eda11b..ae31032 100644 --- a/tests/storage/test_agentcore_unit.py +++ b/tests/storage/test_agentcore_unit.py @@ -3239,3 +3239,10 @@ def test_distinct_projects_empty_when_both_fail(backend, mock_data_client) -> No fixture has no local_conn) degrades all the way to an empty list.""" mock_data_client.list_actors.side_effect = RuntimeError("throttled") assert backend.distinct_projects() == [] + + +def test_agentcore_triggers_unsupported(backend) -> None: + assert backend.supports_triggers is False + assert backend.triggered_candidates(project="p") == [] + with pytest.raises(NotImplementedError): + backend.set_triggers(kind="semantic", id="x", triggers=["tool:X"]) diff --git a/tests/storage/test_protocol.py b/tests/storage/test_protocol.py index f477fa8..55f5bdc 100644 --- a/tests/storage/test_protocol.py +++ b/tests/storage/test_protocol.py @@ -19,6 +19,11 @@ def test_protocol_declares_capability_flag() -> None: assert hasattr(StorageBackend, "supports_synthesis") +def test_protocol_declares_supports_triggers_flag() -> None: + """supports_triggers gates the UI trigger editors and the PreToolUse trigger channel.""" + assert hasattr(StorageBackend, "supports_triggers") + + def test_protocol_declares_supports_episodes_flag() -> None: """supports_episodes is the capability used by management UI to hide the Episodes tab in agentcore mode.""" assert hasattr(StorageBackend, "supports_episodes") @@ -72,6 +77,8 @@ def test_protocol_declares_all_required_methods() -> None: # Semantic memories "semantic_observe", "semantic_list", "semantic_update_text", "semantic_set_scope", "semantic_delete", + # Triggers + "set_triggers", "triggered_candidates", # Episodes "open_background_episode", "start_foreground_episode", "close_active_episode", "close_episode_by_id", "list_episodes", diff --git a/tests/storage/test_sqlite_backend.py b/tests/storage/test_sqlite_backend.py index 755da4b..78960df 100644 --- a/tests/storage/test_sqlite_backend.py +++ b/tests/storage/test_sqlite_backend.py @@ -415,3 +415,57 @@ def test_semantic_get_returns_model(backend, memory_conn): assert backend.semantic_get(id="nope") is None + + +class TestTriggers: + def _seed(self, memory_conn, backend): + sid = backend.semantic_observe(content="rule", project="testproj", scope="project") + memory_conn.execute( + "INSERT INTO reflections (id, title, project, phase, polarity, use_cases, hints, " + "confidence, status, scope, created_at, updated_at) VALUES " + "('r-gen', 'Gen title', 'other', 'general', 'dont', 'uc', '[\"h1\", \"h2\"]', 0.7, " + "'confirmed', 'general', '2026-01-01', '2026-01-01'), " + "('r-ret', 'Retired', 'testproj', 'general', 'do', 'uc', '[]', 0.7, " + "'retired', 'project', '2026-01-01', '2026-01-01')" + ) + memory_conn.commit() + return sid + + def test_supports_triggers(self, backend): + assert backend.supports_triggers is True + + def test_triggered_candidates_returns_only_rows_with_triggers(self, backend, memory_conn): + sid = self._seed(memory_conn, backend) + assert backend.triggered_candidates(project="testproj") == [] + backend.set_triggers(kind="semantic", id=sid, triggers=["tool:WebFetch"]) + cands = backend.triggered_candidates(project="testproj") + assert [c["id"] for c in cands] == [sid] + c = cands[0] + assert c["kind"] == "semantic" and c["text"] == "rule" + assert c["triggers"] == ["tool:WebFetch"] + assert c["polarity"] is None and c["confidence"] is None + assert set(c) >= {"kind", "id", "text", "triggers", "polarity", "confidence", + "useful_count", "updated_at"} + + def test_triggered_candidates_includes_general_scope_reflection_excludes_retired( + self, backend, memory_conn, + ): + self._seed(memory_conn, backend) + backend.set_triggers(kind="reflection", id="r-gen", triggers=["bash:<<"]) + backend.set_triggers(kind="reflection", id="r-ret", triggers=["bash:<<"]) + cands = backend.triggered_candidates(project="testproj") + assert [c["id"] for c in cands] == ["r-gen"] + assert cands[0]["text"] == "Gen title: uc h1 h2" + assert cands[0]["polarity"] == "dont" and cands[0]["confidence"] == 0.7 + + def test_triggered_candidates_skips_malformed_json(self, backend, memory_conn): + sid = self._seed(memory_conn, backend) + memory_conn.execute( + "UPDATE semantic_memories SET triggers='{not json' WHERE id=?", (sid,) + ) + memory_conn.commit() + assert backend.triggered_candidates(project="testproj") == [] + + def test_set_triggers_rejects_unknown_kind(self, backend): + with pytest.raises(ValueError): + backend.set_triggers(kind="episode", id="x", triggers=["tool:X"]) From 9346a80b3e6b4c4f3fea1c7245d6ffd9a4dd2a9c Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:05:17 +0100 Subject: [PATCH 04/16] feat(config): trigger large-write threshold; deferred injection is the default Co-Authored-By: Claude Fable 5.1 --- better_memory/config.py | 23 +++++++++-------- tests/e2e/test_sqlite_journey.py | 4 +++ tests/services/test_session_bootstrap.py | 32 +++++++++++++++--------- tests/test_config.py | 27 +++++++++++++++++--- 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/better_memory/config.py b/better_memory/config.py index 7b1b660..d3aca39 100644 --- a/better_memory/config.py +++ b/better_memory/config.py @@ -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" @@ -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: @@ -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 + ), ) diff --git a/tests/e2e/test_sqlite_journey.py b/tests/e2e/test_sqlite_journey.py index 6f46d7e..4670dbb 100644 --- a/tests/e2e/test_sqlite_journey.py +++ b/tests/e2e/test_sqlite_journey.py @@ -512,6 +512,10 @@ async def test_session_close_rate_then_marker( Fire 2 emits EMPTY stdout and exactly one session_end marker. """ env = isolated_env(clean_slate_home) + # This journey needs a bootstrap exposure to rate; only the legacy + # session-start dump writes one (deferred, the default, renders the + # index line only). The Stop sequence under test is mode-independent. + env["BETTER_MEMORY_INJECT_MODE"] = "legacy" proj_dir = tmp_path / "proj" proj_dir.mkdir() bm_home = clean_slate_home / ".better-memory" diff --git a/tests/services/test_session_bootstrap.py b/tests/services/test_session_bootstrap.py index 6bf280d..49b9931 100644 --- a/tests/services/test_session_bootstrap.py +++ b/tests/services/test_session_bootstrap.py @@ -14,6 +14,14 @@ from better_memory.db.schema import apply_migrations from better_memory.services.session_bootstrap import SessionBootstrapService + +@pytest.fixture(autouse=True) +def _legacy_inject_mode(monkeypatch): + """The rendering tests in this module describe the legacy full dump. + Deferred is the code default since the just-in-time serving change, so + pin legacy here; tests about deferred set the env themselves.""" + monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "legacy") + _MIGRATIONS = Path(__file__).resolve().parents[2] / "better_memory" / "db" / "migrations" @@ -610,7 +618,7 @@ def test_deferred_exposes_only_general_semantics( assert exposed == {("semantic", gid) for gid in gen_ids} assert all(r["source"] == "bootstrap" for r in rows) - def test_legacy_mode_byte_identical( + def test_unset_mode_is_deferred_byte_identical( self, tmp_path: Path, git_repo: Path, monkeypatch ) -> None: from uuid import UUID @@ -665,16 +673,6 @@ def seed(c) -> None: source="startup", session_id="sess-same", cwd=git_repo, ).additional_context - monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "legacy") - conn_legacy = make_conn("legacy.db") - seed(conn_legacy) - svc_legacy = SessionBootstrapService(conn_legacy, clock=lambda: fixed_now) - text_legacy = svc_legacy.bootstrap( - source="startup", session_id="sess-same", cwd=git_repo, - ).additional_context - - assert text_unset == text_legacy - monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "deferred") conn_deferred = make_conn("deferred.db") seed(conn_deferred) @@ -683,4 +681,14 @@ def seed(c) -> None: source="startup", session_id="sess-same", cwd=git_repo, ).additional_context - assert text_deferred != text_unset + assert text_unset == text_deferred + + monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "legacy") + conn_legacy = make_conn("legacy.db") + seed(conn_legacy) + svc_legacy = SessionBootstrapService(conn_legacy, clock=lambda: fixed_now) + text_legacy = svc_legacy.bootstrap( + source="startup", session_id="sess-same", cwd=git_repo, + ).additional_context + + assert text_legacy != text_unset diff --git a/tests/test_config.py b/tests/test_config.py index 29783ee..3dd69d2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -604,14 +604,33 @@ def test_injection_knobs_invalid_raises(monkeypatch): class TestInjectModeConfig: - def test_default_is_legacy(self, monkeypatch): + def test_default_is_deferred(self, monkeypatch): monkeypatch.delenv("BETTER_MEMORY_INJECT_MODE", raising=False) - assert get_config().inject_mode == "legacy" + assert get_config().inject_mode == "deferred" def test_deferred_selected(self, monkeypatch): monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "deferred") assert get_config().inject_mode == "deferred" - def test_unknown_coerces_to_legacy(self, monkeypatch): - monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "yolo") + def test_legacy_opt_in_case_insensitive(self, monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", " Legacy ") assert get_config().inject_mode == "legacy" + + def test_unknown_coerces_to_deferred(self, monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "yolo") + assert get_config().inject_mode == "deferred" + + +class TestTriggerLargeWriteConfig: + def test_default(self, monkeypatch): + monkeypatch.delenv("BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS", raising=False) + assert get_config().trigger_large_write_chars == 8000 + + def test_env_override(self, monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS", "100") + assert get_config().trigger_large_write_chars == 100 + + def test_negative_rejected(self, monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS", "-1") + with pytest.raises(ValueError): + get_config() From 29c1c6d511d4d0a5252e55ff9faafbeaaca3fe2c Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:08:21 +0100 Subject: [PATCH 05/16] feat(relevant): one distinct-hit gate with document-frequency filter for both memory kinds Co-Authored-By: Claude Fable 5.1 --- better_memory/hooks/contextual_inject.py | 1 + better_memory/services/relevant.py | 274 +++++++++++------------ tests/services/test_relevant.py | 125 ++++++++--- 3 files changed, 224 insertions(+), 176 deletions(-) diff --git a/better_memory/hooks/contextual_inject.py b/better_memory/hooks/contextual_inject.py index 1fc04b4..bfc024f 100644 --- a/better_memory/hooks/contextual_inject.py +++ b/better_memory/hooks/contextual_inject.py @@ -146,6 +146,7 @@ def main() -> None: backend, query=query, project=project, conn=conn if cfg.storage_backend == "sqlite" else None, max_items=cfg.context_max_items, + min_hits=cfg.context_min_hits, ) had_candidates = bool(items) pairs = [(m.kind, m.id) for m in items] diff --git a/better_memory/services/relevant.py b/better_memory/services/relevant.py index 1215dc6..493b35f 100644 --- a/better_memory/services/relevant.py +++ b/better_memory/services/relevant.py @@ -1,32 +1,32 @@ """Relevance filter over the curated memory set (semantic + reflections). -Evidence-gated scorer, replacing the old pure-keyword hits-x-activation -model: a memory injects only when it has positive relevance EVIDENCE -- - -- BM25 match against ``reflection_fts`` (title / use_cases / hints), or -- (only when that leg is structurally unavailable -- no sqlite ``conn`` - for reflections; always, for semantics, which have no FTS substrate at - all) a keyword-hit floor as a degraded fallback. - -The Wilson lower-bound prior (see ``services.scoring``) never qualifies a -memory by itself -- it only RANKS among qualifiers via reciprocal rank -fusion (RRF), alongside the BM25 rank. Popularity forcing irrelevant -injections was the old failure mode (13% useful as bootstrap); the gate -exists specifically to close it. - -Fetches the small, already-ranked sets through the StorageBackend -abstraction (works on sqlite AND agentcore); the BM25 leg additionally -requires a raw sqlite ``conn``. Agentcore (``conn=None`` AND -``supports_synthesis=False``) replaces the BM25 leg wholesale with -``backend.relevance_ranks`` -- a server-side semantic-search rank map -- -and falls back to the keyword-hit floor ONLY when that lookup itself -FAILS (``relevance_ranks`` returns ``None`` -- an AWS error). A -successful lookup that genuinely finds nothing (``{}``) does NOT trigger -the fallback -- a legitimate negative result from the server-side gate -must not be overridden by keyword overlap. A sqlite backend called with -``conn=None`` keeps the pre-existing keyword-fallback behavior -unchanged, regardless of whether it also implements ``relevance_ranks`` -(it does, for protocol completeness only). +Evidence-gated scorer. On the sqlite backend ONE gate applies to both +memory kinds (design spec 2026-10-05-just-in-time-serving-design.md §1): + +- tokenise the prompt with ``extract_keywords``; +- drop any token that is *ubiquitous* in the candidate pool -- present in + more than ``DF_CAP`` of the pool's memories, counted only when the pool + has at least ``DF_MIN_POOL`` memories (a smaller pool keeps every token, + otherwise nothing could ever qualify); +- count distinct whole-word hits per memory; a memory qualifies when + ``hits >= min_hits`` (``cfg.context_min_hits``, default 2). + +The replay that motivated this (11 rated sessions) showed the old "any one +BM25 token" qualifier serving web-research pitfalls to a repo-exploration +prompt because both mentioned "Claude Code": a single shared token is not +evidence. Two distinct non-ubiquitous tokens measured 43% useful against +19% for the old gate. + +Ranking among qualifiers is reciprocal rank fusion of the Wilson prior +(``services.scoring``) with a relevance rank: distinct hits descending, +ties broken by BM25 rank over ``reflection_fts`` where that table exists. +The prior never qualifies a memory by itself. + +Agentcore (``conn=None`` AND ``supports_synthesis=False``) is unchanged: +the gate is membership in ``backend.relevance_ranks`` (server-side +semantic search), falling back to a keyword-hit floor ONLY when that +lookup itself FAILS (``None`` -- an AWS error); a genuinely empty ``{}`` is +a legitimate negative. """ from __future__ import annotations @@ -40,11 +40,19 @@ from better_memory.services.keywords import count_keyword_hits, extract_keywords from better_memory.services.scoring import wilson_lower_bound -#: Keyword-hit floor used only when the FTS leg is structurally -#: unavailable (no sqlite conn, for reflections; always, for semantics, -#: which have no FTS substrate at all). +#: Keyword-hit floor for the agentcore fallback (relevance_ranks failed). _FALLBACK_MIN_HITS = 2 +#: A token present in more than this fraction of the candidate pool is +#: ubiquitous and carries no evidence. Measured band: 50% on a pool of 10 +#: dropped "claude", "code", "user" and the like; see spec Assumptions A4. +DF_CAP = 0.5 + +#: The ubiquity filter only runs when the pool has at least this many +#: memories; below it every token in a 1- or 2-memory pool would exceed +#: DF_CAP and nothing could qualify. +DF_MIN_POOL = 4 + #: Reciprocal rank fusion constant, matching search/hybrid.py and #: ReflectionSynthesisService._fuse_by_relevance. _RRF_K = 60 @@ -61,6 +69,7 @@ class RelevantMemory: age_days: int | None hits: int score: float + reason: str | None = None # trigger string that fired (trigger channel only) def _age_days(iso_ts: str | None, now: datetime) -> int | None: @@ -93,6 +102,18 @@ def _bm25_qualifiers(conn: sqlite3.Connection | None, query: str) -> dict[str, i return {row[0]: i for i, row in enumerate(rows)} +def _ubiquitous(token_sets: list[set[str]]) -> set[str]: + """Tokens present in more than DF_CAP of the pool (pool >= DF_MIN_POOL).""" + n = len(token_sets) + if n < DF_MIN_POOL: + return set() + counts: dict[str, int] = {} + for toks in token_sets: + for t in toks: + counts[t] = counts.get(t, 0) + 1 + return {t for t, c in counts.items() if c > DF_CAP * n} + + def _wilson_for(useful: int, overlooked: int, ignored: int) -> float: positive = useful + overlooked n = useful + overlooked + ignored @@ -100,12 +121,12 @@ def _wilson_for(useful: int, overlooked: int, ignored: int) -> float: def _rrf_score(candidates: list[dict]) -> list[tuple[float, dict]]: - """RRF-fuse the Wilson prior with the BM25 rank already stashed - on each candidate dict (``bm_rank``, ``None`` if absent). + """RRF-fuse the Wilson prior with the relevance rank stashed on each + candidate dict (``rel_rank``, ``None`` if absent). - The prior rank is computed fresh here (desc by Wilson score) rather - than carried in, since it only makes sense relative to the other - qualifiers in this same candidate set. + Both ranks are computed relative to this candidate set: the prior rank + by Wilson score descending; the relevance rank by distinct hits + descending, then ``bm_rank`` ascending (None last), then id. """ order_by_wilson = sorted(range(len(candidates)), key=lambda i: -candidates[i]["wilson"]) prior_rank = {candidates[i]["id"]: rank for rank, i in enumerate(order_by_wilson)} @@ -113,13 +134,22 @@ def _rrf_score(candidates: list[dict]) -> list[tuple[float, dict]]: scored: list[tuple[float, dict]] = [] for c in candidates: present_ranks = [prior_rank[c["id"]]] - if c["bm_rank"] is not None: - present_ranks.append(c["bm_rank"]) + if c.get("rel_rank") is not None: + present_ranks.append(c["rel_rank"]) score = sum(1.0 / (_RRF_K + rank) for rank in present_ranks) scored.append((score, c)) return scored +def _assign_rel_ranks(candidates: list[dict]) -> None: + """Set ``rel_rank`` on each candidate: hits desc, bm_rank asc (None last), id.""" + def key(c: dict): + bm = c.get("bm_rank") + return (-c["hits"], bm if bm is not None else 10**9, c["id"]) + for rank, c in enumerate(sorted(candidates, key=key)): + c["rel_rank"] = rank + + def retrieve_relevant( backend: Any, *, @@ -129,26 +159,17 @@ def retrieve_relevant( max_items: int = 3, include_neutral: bool = False, now: Callable[[], datetime] | None = None, + min_hits: int = 2, ) -> list[RelevantMemory]: """Gate + rank curated memories (semantic + reflections) for ``query``. - A memory is returned only if it clears the evidence gate: a BM25 match - (reflections), or (only when that leg is structurally unavailable -- - no sqlite ``conn`` for reflections; always, for semantics, which have - no FTS leg at all) a keyword-hit fallback. Among qualifiers, ranking - is RRF over the Wilson prior plus the BM25 rank when present. - - Agentcore backends (``conn=None`` AND ``supports_synthesis=False``) - replace the BM25 leg with ``backend.relevance_ranks`` -- a - server-side semantic-search rank map fused into the same RRF -- and - fall back to the keyword-hit floor only when that lookup FAILS - (returns ``None`` -- an AWS error), never merely because it found - nothing (``{}`` is a legitimate negative result the gate must - respect). Sqlite backends are unaffected by this branch even when - called with ``conn=None``. - - Never raises -- any backend/leg failure degrades that leg to "absent" - rather than propagating. + Sqlite: a memory is returned only when at least ``min_hits`` distinct + non-ubiquitous query tokens appear in it (see module docstring). + Agentcore (``conn=None`` AND ``supports_synthesis=False``): membership + in ``backend.relevance_ranks``, with the keyword-hit floor only when + that lookup FAILS (``None``). Ranking is RRF of the Wilson prior and + the relevance rank. Never raises -- any backend/leg failure degrades + that leg to "absent". """ if not (query or "").strip(): return [] @@ -164,36 +185,17 @@ def retrieve_relevant( except Exception: # noqa: BLE001 - degrade to no semantic semantic = [] - bm = _bm25_qualifiers(conn, query) refl_bucket_order = ["do", "dont"] + (["neutral"] if include_neutral else []) - keywords = extract_keywords(query) # fallback evidence only - - fts_unavailable = conn is None - - # Agentcore evidence gate (design spec 2026-07-24-agentcore-parity- - # design.md §3): when the caller has no sqlite FTS substrate - # (conn=None) AND the backend is agentcore-flavored -- - # supports_synthesis=False, the existing Protocol capability flag that - # already distinguishes AgentCoreBackend from SqliteBackend everywhere - # else in this codebase -- the evidence gate becomes membership in a - # backend-computed relevance rank map (server-side RetrieveMemoryRecords - # semantic search) instead of the keyword-hit fallback. A conn=None - # SqliteBackend caller (supports_synthesis=True) is explicitly excluded - # here, so sqlite's own fallback semantics stay byte-for-byte unchanged - # regardless of whether SqliteBackend also implements relevance_ranks - # (it does, for protocol completeness -- see storage/sqlite.py -- but - # this function never calls it in that case). + agentcore_mode = ( conn is None and hasattr(backend, "relevance_ranks") and getattr(backend, "supports_synthesis", True) is False ) - # None vs {} from relevance_ranks is load-bearing (see - # StorageBackend.relevance_ranks's docstring): None means the - # backend-side lookup itself failed (AWS error on every namespace) -- - # THAT is the keyword-fallback trigger. {} means the lookup ran fine - # and genuinely found nothing, which must NOT re-qualify memories via - # keyword overlap -- the server-side gate's negative result stands. + # None vs {} from relevance_ranks is load-bearing: None means the lookup + # itself failed (AWS error) -- THAT is the keyword-fallback trigger. {} + # means it ran fine and found nothing, which must NOT re-qualify + # memories via keyword overlap. raw_rank_map: dict[tuple[str, str], int] | None = None if agentcore_mode: try: @@ -205,94 +207,83 @@ def retrieve_relevant( agentcore_kw_fallback = agentcore_mode and raw_rank_map is None rank_map: dict[tuple[str, str], int] = raw_rank_map or {} - refl_candidates: list[dict] = [] + # Flatten the pool once: (kind, id, display text, polarity, row/obj). + pool: list[dict] = [] for bucket in refl_bucket_order: for r in buckets.get(bucket, []) or []: - r_id = str(r.get("id")) title = str(r.get("title") or "") body = " ".join( [str(r.get("use_cases") or "")] + [str(h) for h in (r.get("hints") or [])] ) - text = f"{title} {body}" - kw_hits = count_keyword_hits(text, keywords) - - in_bm = r_id in bm - in_backend_rank = agentcore_mode and ("reflection", r_id) in rank_map - fallback_ok = ( - (agentcore_kw_fallback if agentcore_mode else fts_unavailable) - and kw_hits >= _FALLBACK_MIN_HITS - ) - if not (in_bm or in_backend_rank or fallback_ok): - continue - - refl_candidates.append({ - "id": r_id, "kind": "reflection", - "polarity": bucket if bucket in ("do", "dont") else None, + pool.append({ + "kind": "reflection", "id": str(r.get("id")), + "match_text": f"{title} {body}", "text": f"{title}: {body}".strip(": "), + "polarity": bucket if bucket in ("do", "dont") else None, "confidence": r.get("confidence"), "useful_count": int(r.get("useful_count") or 0), "age_days": _age_days(r.get("updated_at"), _now), - "hits": kw_hits if (in_bm or in_backend_rank or fallback_ok) else 0, - "bm_rank": ( - rank_map.get(("reflection", r_id)) - if agentcore_mode else bm.get(r_id) - ), - # storage.protocol.retrieve guarantees times_overlooked/ - # times_ignored on both backends (sqlite columns; agentcore - # copies its internal overlooked counter and hardcodes - # ignored=0 — see storage/agentcore.py::_parse_reflection_record). - # The .get(...) defaults below are defensive, not load-bearing. "wilson": _wilson_for( int(r.get("useful_count") or 0), int(r.get("times_overlooked") or 0), int(r.get("times_ignored") or 0), ), }) - - sem_candidates: list[dict] = [] - for s in semantic or []: - s_id = str(getattr(s, "id", "")) - content = getattr(s, "content", "") or "" - kw_hits = count_keyword_hits(content, keywords) - - in_backend_rank = agentcore_mode and ("semantic", s_id) in rank_map - # Semantics have no FTS/BM25 leg and no vec leg either, so the - # keyword-hit floor is their only evidence leg outside agentcore - # mode -- it is always "on" here (there is no other leg to gate - # it behind). In agentcore mode the backend rank map replaces it, - # so the fallback there fires only per the same - # agentcore_kw_fallback signal as reflections (relevance_ranks - # returned None == AWS error; a genuinely empty {} does NOT - # trigger it). - fallback_ok = ( - agentcore_kw_fallback if agentcore_mode else True - ) and kw_hits >= _FALLBACK_MIN_HITS - if not (in_backend_rank or fallback_ok): - continue - - sem_candidates.append({ - "id": s_id, "kind": "semantic", "polarity": None, - "text": content, + for s_ in semantic or []: + content = getattr(s_, "content", "") or "" + pool.append({ + "kind": "semantic", "id": str(getattr(s_, "id", "")), + "match_text": content, "text": content, "polarity": None, "confidence": None, - "useful_count": int(getattr(s, "useful_count", 0) or 0), - "age_days": _age_days(getattr(s, "updated_at", None), _now), - "hits": kw_hits if (in_backend_rank or fallback_ok) else 0, - "bm_rank": ( - rank_map.get(("semantic", s_id)) - if agentcore_mode else None - ), + "useful_count": int(getattr(s_, "useful_count", 0) or 0), + "age_days": _age_days(getattr(s_, "updated_at", None), _now), "wilson": _wilson_for( - int(getattr(s, "useful_count", 0) or 0), - int(getattr(s, "times_overlooked", 0) or 0), - int(getattr(s, "times_ignored", 0) or 0), + int(getattr(s_, "useful_count", 0) or 0), + int(getattr(s_, "times_overlooked", 0) or 0), + int(getattr(s_, "times_ignored", 0) or 0), ), }) + raw_keywords = extract_keywords(query) + if agentcore_mode: + keywords = raw_keywords # fallback evidence only + else: + ubiquitous = _ubiquitous([extract_keywords(c["match_text"]) for c in pool]) + keywords = raw_keywords - ubiquitous + bm = _bm25_qualifiers(conn, query) if not agentcore_mode else {} + + qualifiers: list[dict] = [] + for c in pool: + kw_hits = count_keyword_hits(c["match_text"], keywords) + key = (c["kind"], c["id"]) + if agentcore_mode: + in_backend_rank = key in rank_map + fallback_ok = agentcore_kw_fallback and kw_hits >= _FALLBACK_MIN_HITS + if not (in_backend_rank or fallback_ok): + continue + c["bm_rank"] = rank_map.get(key) + else: + if kw_hits < min_hits: + continue + c["bm_rank"] = bm.get(c["id"]) if c["kind"] == "reflection" else None + c["hits"] = kw_hits + qualifiers.append(c) + + refl_candidates = [c for c in qualifiers if c["kind"] == "reflection"] + sem_candidates = [c for c in qualifiers if c["kind"] == "semantic"] + if agentcore_mode: + # Preserve the backend's own ordering as the relevance rank. + for c in refl_candidates + sem_candidates: + c["rel_rank"] = c["bm_rank"] + else: + _assign_rel_ranks(refl_candidates) + _assign_rel_ranks(sem_candidates) + all_scored = _rrf_score(refl_candidates) + _rrf_score(sem_candidates) all_scored.sort(key=lambda t: (-t[0], t[1]["id"])) - out = [ + return [ RelevantMemory( kind=c["kind"], id=c["id"], text=c["text"], polarity=c["polarity"], confidence=c["confidence"], useful_count=c["useful_count"], @@ -300,7 +291,6 @@ def retrieve_relevant( ) for score, c in all_scored[:max_items] ] - return out _TEXT_MAX_CHARS = 400 diff --git a/tests/services/test_relevant.py b/tests/services/test_relevant.py index 2df0c9c..1f26f8f 100644 --- a/tests/services/test_relevant.py +++ b/tests/services/test_relevant.py @@ -1,11 +1,11 @@ -"""Contextual relevance: BM25 + Wilson prior behind an evidence gate. - -The gate is the point: a memory injects only with positive relevance -evidence (BM25 match on the query for reflections; a keyword-hit floor for -semantics, which have no FTS leg of their own). The Wilson prior RANKS -qualifiers but can never qualify a memory alone -- popularity must not -force irrelevant injections (that failure mode measured 13% useful as -bootstrap). +"""Contextual relevance: a distinct-hit evidence gate, Wilson prior for rank. + +The gate is the point: a memory injects only when at least ``min_hits`` +distinct, non-ubiquitous query tokens appear in it (same rule for +reflections and semantics). The Wilson prior RANKS qualifiers but can never +qualify a memory alone -- popularity must not force irrelevant injections +(that failure mode measured 13% useful as bootstrap; a single shared token +such as "claude" measured 19% useful across all channels). """ from __future__ import annotations @@ -69,24 +69,91 @@ def _seed_semantic( conn.commit() -class TestBM25Gate: - def test_bm25_match_qualifies(self, conn): - _seed_reflection(conn, "r1", title="Retention archives by confidence") - backend = _backend(conn) +class TestDistinctHitGate: + """One gate for both kinds: >= min_hits distinct non-ubiquitous query + tokens must appear as whole words in the memory text.""" + + def test_single_shared_token_does_not_qualify(self, conn): + _seed_reflection(conn, "r1", title="Use Playwright for Amazon pages") out = retrieve_relevant( - backend, query="how does retention archive things", project="p", + _backend(conn), query="look at the amazon repo", project="p", + conn=conn, now=lambda: FIXED_NOW, + ) + assert out == [] + + def test_two_distinct_hits_qualify(self, conn): + _seed_reflection(conn, "r1", title="Use Playwright for Amazon pages") + out = retrieve_relevant( + _backend(conn), query="amazon playwright", project="p", conn=conn, now=lambda: FIXED_NOW, ) assert [m.id for m in out] == ["r1"] + assert out[0].hits == 2 def test_no_evidence_no_injection(self, conn): _seed_reflection(conn, "r1", title="Zebra flamingo unrelated topic", useful=50) - backend = _backend(conn) out = retrieve_relevant( - backend, query="how does retention archive things", project="p", + _backend(conn), query="how does retention archive things", project="p", + conn=conn, now=lambda: FIXED_NOW, + ) + assert out == [] + + def test_min_hits_floor_respected(self, conn): + _seed_reflection(conn, "r1", title="Use Playwright for Amazon pages") + out = retrieve_relevant( + _backend(conn), query="amazon playwright", project="p", + conn=conn, now=lambda: FIXED_NOW, min_hits=3, + ) + assert out == [] + + def test_ubiquitous_tokens_dropped(self, conn): + # Five memories all mention "claude code"; those two tokens carry no + # evidence and must not qualify anything by themselves. + for i in range(5): + _seed_reflection(conn, f"r{i}", title=f"Claude Code pitfall number {i}") + out = retrieve_relevant( + _backend(conn), query="claude code research", project="p", conn=conn, now=lambda: FIXED_NOW, ) assert out == [] + # ...but a memory with two further distinct hits still qualifies. + _seed_reflection(conn, "r-web", title="Claude Code web research needs forums") + out = retrieve_relevant( + _backend(conn), query="claude code web research", project="p", + conn=conn, now=lambda: FIXED_NOW, + ) + assert [m.id for m in out] == ["r-web"] + + def test_small_pool_keeps_all_tokens(self, conn): + # Pool below DF_MIN_POOL: nothing is "ubiquitous", so shared tokens + # still count and both memories qualify on two hits. + _seed_reflection(conn, "r1", title="Claude Code heredoc pitfall") + _seed_reflection(conn, "r2", title="Claude Code reddit pitfall") + out = retrieve_relevant( + _backend(conn), query="claude code", project="p", + conn=conn, now=lambda: FIXED_NOW, + ) + assert sorted(m.id for m in out) == ["r1", "r2"] + + def test_semantic_and_reflection_share_gate(self, conn): + _seed_reflection(conn, "r1", title="Use Playwright for Amazon pages") + _seed_semantic(conn, "s1", content="Amazon pages need the Playwright MCP") + _seed_semantic(conn, "s2", content="Amazon is a retailer") # one hit only + out = retrieve_relevant( + _backend(conn), query="amazon playwright", project="p", + conn=conn, now=lambda: FIXED_NOW, + ) + assert sorted((m.kind, m.id) for m in out) == [("reflection", "r1"), ("semantic", "s1")] + + def test_hits_rank_above_wilson(self, conn): + _seed_reflection(conn, "r-two", title="Retention thresholds alpha", useful=9, ignored=1) + _seed_reflection(conn, "r-three", title="Retention thresholds archive beta", + useful=0, ignored=6) + out = retrieve_relevant( + _backend(conn), query="retention thresholds archive", project="p", + conn=conn, now=lambda: FIXED_NOW, + ) + assert [m.id for m in out] == ["r-three", "r-two"] class TestWilsonRanking: @@ -104,26 +171,12 @@ def test_wilson_ranks_among_qualifiers(self, conn): class TestSemantics: - def test_semantic_fallback_keyword_when_no_embedder(self, conn): + def test_semantic_gate_when_conn_none(self, conn): + """A sqlite backend called with ``conn=None`` applies the same + distinct-hit gate: semantics never needed the FTS table.""" _seed_semantic(conn, "s1", content="repo uses uv run pytest on windows") - backend = _backend(conn) - out = retrieve_relevant( - backend, query="uv run pytest windows setup", project="p", - conn=conn, now=lambda: FIXED_NOW, - ) - assert [(m.kind, m.id) for m in out] == [("semantic", "s1")] - - def test_semantic_fallback_keyword_when_conn_none(self, conn): - """Semantics have no vec/FTS leg at all, so the keyword-hit floor - is their only evidence leg regardless of ``conn`` -- a sqlite - backend called with ``conn=None`` keeps the same keyword-fallback - behaviour as ``conn=conn`` (see services/relevant.py's module - docstring: 'a sqlite backend called with conn=None keeps the - pre-existing keyword-fallback behavior unchanged').""" - _seed_semantic(conn, "s1", content="repo uses uv run pytest on windows") - backend = _backend(conn) out = retrieve_relevant( - backend, query="uv run pytest windows setup", project="p", + _backend(conn), query="uv run pytest windows setup", project="p", conn=None, now=lambda: FIXED_NOW, ) assert [(m.kind, m.id) for m in out] == [("semantic", "s1")] @@ -133,6 +186,10 @@ class TestCapsAndDegradation: def test_max_items_cap(self, conn): for i in range(5): _seed_reflection(conn, f"r{i}", title=f"Retention thresholds variant {i}") + # Pad the pool so "retention"/"thresholds" stay under the ubiquity + # cap (5 of 11 memories); the cap, not the gate, is under test. + for i in range(6): + _seed_reflection(conn, f"z{i}", title=f"Zebra flamingo unrelated {i}") backend = _backend(conn) out = retrieve_relevant( backend, query="retention thresholds", project="p", @@ -364,7 +421,7 @@ def test_returns_relevantmemory(conn): _seed_reflection(conn, "r1", title="Retention archives by confidence") backend = _backend(conn) out = retrieve_relevant( - backend, query="how does retention archive things", project="p", + backend, query="how does retention archives confidence work", project="p", conn=conn, now=lambda: FIXED_NOW, ) assert out and all(isinstance(m, RelevantMemory) for m in out) From fbcaf540b5b4f935490b6a8899fa39df84166895 Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:09:12 +0100 Subject: [PATCH 06/16] feat(hook): skip non-human prompts in contextual injection Co-Authored-By: Claude Fable 5.1 --- better_memory/hooks/contextual_inject.py | 22 +++++++++++++ tests/hooks/test_contextual_inject.py | 41 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/better_memory/hooks/contextual_inject.py b/better_memory/hooks/contextual_inject.py index bfc024f..37d1f3e 100644 --- a/better_memory/hooks/contextual_inject.py +++ b/better_memory/hooks/contextual_inject.py @@ -63,6 +63,22 @@ def _bump_diagnostic(conn, cfg, metric: str) -> None: pass +#: Prompt prefixes that mark a UserPromptSubmit payload as NOT typed by a +#: human: command output and system tags (``<...>``) and peer-agent +#: messages. The hook payload carries no origin field, so the text is the +#: only signal. Measured: the hook fired 571 times across sessions holding +#: 261 typed prompts and 296 peer messages (spec Assumption A5). +NON_HUMAN_PREFIXES: tuple[str, ...] = ("<", "Another Claude session sent a message") + + +def is_human_prompt(text: str) -> bool: + """True for a non-empty prompt that does not start with a non-human prefix.""" + stripped = (text or "").strip() + if not stripped: + return False + return not stripped.startswith(NON_HUMAN_PREFIXES) + + def _enabled(event: str, mode: str) -> bool: if mode == "off": return False @@ -115,6 +131,12 @@ def main() -> None: # and short-circuit before opening any DB connection. if not seen.try_claim_pretool_fired(): raise _SkipInjection() # module-local sentinel; caught below + if event == "UserPromptSubmit" and not is_human_prompt(query): + # Peer-agent messages, command output and system tags are + # not prompts: no injection, no exposure, no turn bump. + with closing(connect(cfg.memory_db)) as conn: + _bump_diagnostic(conn, cfg, "contextual_suppressed_nonhuman") + raise _SkipInjection() seen.bump_turn() # A real local connection is opened in BOTH modes now. Agentcore # mode never stores memory CONTENT locally, but session- diff --git a/tests/hooks/test_contextual_inject.py b/tests/hooks/test_contextual_inject.py index c3cbcc5..5623c7d 100644 --- a/tests/hooks/test_contextual_inject.py +++ b/tests/hooks/test_contextual_inject.py @@ -406,3 +406,44 @@ def test_userprompt_unaffected_by_pretool_latch(bm_home, monkeypatch, capsys): } res = _run(prompt_payload, monkeypatch, capsys) assert res["hookSpecificOutput"]["additionalContext"] != "" + + +@pytest.mark.parametrize("text,human", [ + ("fix the bug", True), + (" x", False), + ("Another Claude session sent a message: hi", False), + (" ", False), + ("", False), + (" ls", False), + ("deploy widget", True), +]) +def test_nonhuman_prefix_detection(text, human): + assert hook.is_human_prompt(text) is human + + +def test_peer_message_is_suppressed(bm_home, monkeypatch, capsys): + _seed_reflection(bm_home, "refl-widget-deploy-peer", title="widget deploy playbook") + res = _run( + {"hook_event_name": "UserPromptSubmit", + "prompt": "Another Claude session sent a message: deploy the widget now", + "cwd": ".", "session_id": "sess-peer"}, + monkeypatch, capsys, + ) + assert res["hookSpecificOutput"]["additionalContext"] == "" + conn = connect(bm_home / "memory.db") + try: + n = conn.execute( + "SELECT COUNT(*) FROM session_memory_exposure WHERE session_id = ?", + ("sess-peer",), + ).fetchone()[0] + finally: + conn.close() + assert n == 0 + assert _diag_value(bm_home, "contextual_suppressed_nonhuman") == 1 + # A later human prompt in the same session is still served. + res = _run( + {"hook_event_name": "UserPromptSubmit", "prompt": "deploy the widget service now", + "cwd": ".", "session_id": "sess-peer"}, + monkeypatch, capsys, + ) + assert "refl-widget-deploy-peer" in res["hookSpecificOutput"]["additionalContext"] From 590e67aa49a3d71a71264a0aa38400b6298bbe2e Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:15:53 +0100 Subject: [PATCH 07/16] feat(hook): trigger channel on every PreToolUse; drop the per-session latch Adds migration 0020 widening the exposure source CHECK to 'trigger'. Co-Authored-By: Claude Fable 5.1 --- .../0020_exposure_source_trigger.sql | 37 ++++ better_memory/hooks/contextual_inject.py | 112 +++++------ better_memory/services/context_seen.py | 48 +---- better_memory/services/relevant.py | 46 +++++ better_memory/setup/manifest.py | 7 +- ..._migration_0020_exposure_source_trigger.py | 88 +++++++++ tests/hooks/test_contextual_inject.py | 176 +++++++++++++----- tests/services/test_context_seen.py | 47 ++--- tests/services/test_relevant_format.py | 6 + 9 files changed, 387 insertions(+), 180 deletions(-) create mode 100644 better_memory/db/migrations/0020_exposure_source_trigger.sql create mode 100644 tests/db/test_migration_0020_exposure_source_trigger.py diff --git a/better_memory/db/migrations/0020_exposure_source_trigger.sql b/better_memory/db/migrations/0020_exposure_source_trigger.sql new file mode 100644 index 0000000..e8251d6 --- /dev/null +++ b/better_memory/db/migrations/0020_exposure_source_trigger.sql @@ -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); diff --git a/better_memory/hooks/contextual_inject.py b/better_memory/hooks/contextual_inject.py index 37d1f3e..afbd671 100644 --- a/better_memory/hooks/contextual_inject.py +++ b/better_memory/hooks/contextual_inject.py @@ -1,25 +1,27 @@ """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. - -Candidates are scored via retrieve_relevant's evidence gate (BM25 -qualifiers, a keyword-hit fallback when no FTS substrate is available, or -the backend's relevance_ranks in agentcore mode — see -services/relevant.py) and capped at cfg.context_max_items. A per-session -SeenStore dedups injected memories across turns within a run -(cfg.context_reinject_turns controls re-injection after N turns). -Survivors are recorded as 'contextual' exposures (best-effort; -a write failure never blocks injection) and counted in rating_diagnostics for -observability (contextual_fired_userprompt/pretool, contextual_injected, -contextual_suppressed_floor, contextual_suppressed_dedup). - -PreToolUse is latched to one real firing per session -(SeenStore.try_claim_pretool_fired — an atomic O_CREAT|O_EXCL claim on a -sentinel file, race-safe across parallel hook processes): the installed -matcher is unscoped (all tools), so without the latch every tool call -would re-run the full retrieval path. Later PreToolUse events in the same -session short-circuit on the sentinel before any DB work. -UserPromptSubmit is unaffected by the latch. +current prompt (typed-prompt channel) or tool call (trigger channel). Gated +by BETTER_MEMORY_CONTEXT_INJECT_MODE (userprompt | pretool | both | off). +Never raises; always exits 0. + +UserPromptSubmit: non-human prompts (command output, system tags, peer +agent messages) are skipped outright; human prompts go through +retrieve_relevant's distinct-hit evidence gate (services/relevant.py) and +are capped at cfg.context_max_items. + +PreToolUse fires on EVERY tool call (no per-session latch any more) and is +trigger-only: memories carrying triggers (services/triggers.py grammar) +are matched against the tool name + input by triggered_memories; nothing +is keyword-matched against tool input. Measured cost of the full path is +within noise of the old latched short-circuit (184 ms vs 175 ms, of which +168 ms is interpreter start). Hits are logged with exposure source +'trigger' so the diagnostics panel can report the channel on its own. + +A per-session SeenStore dedups injected memories across both channels and +across turns (cfg.context_reinject_turns controls re-injection after N +turns). Exposure writes are best-effort -- a failure never blocks +injection. Counters in rating_diagnostics: contextual_fired_userprompt / +pretool, contextual_injected, contextual_suppressed_floor / dedup / +nonhuman, trigger_fired, trigger_injected. """ from __future__ import annotations @@ -34,17 +36,20 @@ from better_memory.db.connection import connect from better_memory.hooks._error_log import record_hook_error from better_memory.services.context_seen import SeenStore, prune_stale -from better_memory.services.relevant import format_relevant, retrieve_relevant +from better_memory.services.relevant import ( + format_relevant, + retrieve_relevant, + triggered_memories, +) from better_memory.storage import build_backend _MAX_STDIN_BYTES = 1_000_000 class _SkipInjection(Exception): - """Module-local sentinel: PreToolUse latch already fired this session. - - Caught explicitly (never via the outer BaseException guard) to leave - ``rendered = ""`` without treating the skip as an error. + """Module-local sentinel: this firing injects nothing by design (a + non-human prompt). Caught explicitly (never via the outer BaseException + guard) to leave ``rendered = ""`` without treating the skip as an error. """ @@ -125,12 +130,6 @@ def main() -> None: state_dir = cfg.home / "state" prune_stale(state_dir, now=datetime.now(UTC)) seen = SeenStore(state_dir, session_id) - if event == "PreToolUse": - # Atomic O_CREAT|O_EXCL claim: if another parallel hook - # process already fired for this session, we return False - # and short-circuit before opening any DB connection. - if not seen.try_claim_pretool_fired(): - raise _SkipInjection() # module-local sentinel; caught below if event == "UserPromptSubmit" and not is_human_prompt(query): # Peer-agent messages, command output and system tags are # not prompts: no injection, no exposure, no turn bump. @@ -138,20 +137,12 @@ def main() -> None: _bump_diagnostic(conn, cfg, "contextual_suppressed_nonhuman") raise _SkipInjection() seen.bump_turn() - # A real local connection is opened in BOTH modes now. Agentcore - # mode never stores memory CONTENT locally, but session- - # operational state (the exposure ledger) lives in the local - # memory.db regardless of backend — build_backend threads this - # conn through as the backend's exposure-ledger connection. The - # BM25 legs below still require the SQLITE-CONTENT - # substrate (reflection_fts), which agentcore has none of, so - # retrieve_relevant still gets conn=None for agentcore -- that - # parameter means "FTS index available", not "any local - # connection at all". retrieve_relevant itself detects - # agentcore (conn=None + supports_synthesis=False) and - # substitutes backend.relevance_ranks — a server-side semantic - # search — for the BM25 legs, so agentcore's evidence gate is - # no longer purely keyword-based despite conn=None here. + # A real local connection is opened in BOTH backend modes: the + # exposure ledger is session-operational state in the local + # memory.db regardless of where memory CONTENT lives. The + # ``conn`` passed to retrieve_relevant means "sqlite FTS + # substrate available", so agentcore gets None there and + # retrieve_relevant swaps in backend.relevance_ranks. with closing(connect(cfg.memory_db)) as conn: _bump_diagnostic( conn, cfg, @@ -164,18 +155,31 @@ def main() -> None: session_id=session_id or None, project=project, ) - items = retrieve_relevant( - backend, query=query, project=project, - conn=conn if cfg.storage_backend == "sqlite" else None, - max_items=cfg.context_max_items, - min_hits=cfg.context_min_hits, - ) + if event == "PreToolUse": + _bump_diagnostic(conn, cfg, "trigger_fired") + tool_input = payload.get("tool_input") + items = triggered_memories( + backend, project=project, + tool_name=str(payload.get("tool_name") or ""), + tool_input=tool_input if isinstance(tool_input, dict) else {}, + large_write_chars=cfg.trigger_large_write_chars, + ) + source, injected_metric = "trigger", "trigger_injected" + else: + items = retrieve_relevant( + backend, query=query, project=project, + conn=conn if cfg.storage_backend == "sqlite" else None, + max_items=cfg.context_max_items, + min_hits=cfg.context_min_hits, + ) + source, injected_metric = "contextual", "contextual_injected" had_candidates = bool(items) pairs = [(m.kind, m.id) for m in items] unseen = set(seen.filter_unseen( pairs, reinject_turns=cfg.context_reinject_turns, )) items = [m for m in items if (m.kind, m.id) in unseen] + items = items[: cfg.context_max_items] if items: rendered = format_relevant(items) survivors = [(m.kind, m.id) for m in items] @@ -184,7 +188,7 @@ def main() -> None: backend.record_exposures( session_id=session_id, items=exposure_items, - source="contextual", + source=source, ) except BaseException as exc: # noqa: BLE001 - never block injection try: @@ -192,7 +196,7 @@ def main() -> None: except BaseException: # noqa: BLE001 pass seen.mark_seen(survivors) - _bump_diagnostic(conn, cfg, "contextual_injected") + _bump_diagnostic(conn, cfg, injected_metric) elif had_candidates: _bump_diagnostic(conn, cfg, "contextual_suppressed_dedup") else: diff --git a/better_memory/services/context_seen.py b/better_memory/services/context_seen.py index c9f53f2..5509feb 100644 --- a/better_memory/services/context_seen.py +++ b/better_memory/services/context_seen.py @@ -13,12 +13,10 @@ a partial JSON, and mutators re-read the on-disk snapshot before merging so a second process's writes are not silently clobbered. -The PreToolUse "one real firing per session" latch is a sibling sentinel -file ``context_seen_.pretool`` claimed atomically via -``os.open(..., O_CREAT|O_EXCL)``. Only :meth:`SeenStore.try_claim_pretool_fired` -is race-free; the older read-then-write pair (:meth:`pretool_fired` / -:meth:`mark_pretool_fired`) is retained for compatibility but callers on -the concurrent path must use the atomic claim. +Older versions kept a PreToolUse "one firing per session" latch as a +sibling ``context_seen_.pretool`` sentinel. The latch is gone +(PreToolUse is trigger-only and fires on every call); :func:`prune_stale` +still sweeps leftover sentinels from upgraded installs. """ from __future__ import annotations @@ -29,8 +27,9 @@ from datetime import datetime from pathlib import Path -# Matches the state JSON, the pretool sentinel, and stray `.tmp` siblings -# left behind by a process hard-killed between mkstemp and os.replace. +# Matches the state JSON, legacy pretool sentinels, and stray `.tmp` +# siblings left behind by a process hard-killed between mkstemp and +# os.replace. _FILE_RE = re.compile(r"^context_seen_.+\.(json|pretool|json\..+\.tmp)$") _SAFE_SESSION_RE = re.compile(r"[^A-Za-z0-9_.-]") @@ -44,7 +43,6 @@ def __init__(self, state_dir: Path, session_id: str) -> None: self._dir = state_dir safe = _SAFE_SESSION_RE.sub("_", session_id or "unknown") self._path = state_dir / f"context_seen_{safe}.json" - self._sentinel = state_dir / f"context_seen_{safe}.pretool" self._data = self._load() def _load(self) -> dict: @@ -125,38 +123,6 @@ def mark_seen(self, ids: list[tuple[str, str]]) -> None: self._data = {"turn": turn, "seen": merged_seen} self._save() - def try_claim_pretool_fired(self) -> bool: - """Atomic check-and-set on a sentinel file. - - Returns True iff this call created the sentinel (i.e. this process - is the first firing this session); False if the sentinel already - exists or the claim could not be established. Safe to race across - processes: ``O_CREAT|O_EXCL`` guarantees at most one caller sees - the True return per session. - """ - try: - self._dir.mkdir(parents=True, exist_ok=True) - fd = os.open( - str(self._sentinel), - os.O_CREAT | os.O_EXCL | os.O_WRONLY, - 0o644, - ) - os.close(fd) - return True - except FileExistsError: - return False - except BaseException: # noqa: BLE001 - best-effort; behave as "already claimed" - return False - - def pretool_fired(self) -> bool: - return self._sentinel.exists() - - def mark_pretool_fired(self) -> None: - # Retained for compatibility; the sentinel is idempotent so - # racing callers converge on the same on-disk state, though only - # try_claim_pretool_fired() distinguishes the winner. - self.try_claim_pretool_fired() - def prune_stale(state_dir: Path, *, now: datetime, max_age_days: int = 7) -> None: """Delete context_seen state / pretool sentinels older than max_age_days. diff --git a/better_memory/services/relevant.py b/better_memory/services/relevant.py index 493b35f..45b6f14 100644 --- a/better_memory/services/relevant.py +++ b/better_memory/services/relevant.py @@ -39,6 +39,7 @@ from better_memory.search.query import sanitize_fts5_query from better_memory.services.keywords import count_keyword_hits, extract_keywords from better_memory.services.scoring import wilson_lower_bound +from better_memory.services.triggers import match as match_trigger #: Keyword-hit floor for the agentcore fallback (relevance_ranks failed). _FALLBACK_MIN_HITS = 2 @@ -293,6 +294,49 @@ def retrieve_relevant( ] +def triggered_memories( + backend: Any, + *, + project: str, + tool_name: str, + tool_input: dict | None, + large_write_chars: int, + now: Callable[[], datetime] | None = None, +) -> list[RelevantMemory]: + """Trigger channel (PreToolUse): every memory whose triggers fire for + this tool call, best Wilson prior first. ``reason`` carries the trigger + string that fired. No keyword matching happens here -- a memory without + triggers is never tool-triggered. Never raises: a backend failure + yields [].""" + _now = (now or (lambda: datetime.now(UTC)))() + try: + candidates = backend.triggered_candidates(project=project) + except Exception: # noqa: BLE001 - degrade to no trigger candidates + return [] + out: list[RelevantMemory] = [] + for c in candidates or []: + reason = match_trigger( + list(c.get("triggers") or []), tool_name, tool_input, + large_write_chars=large_write_chars, + ) + if reason is None: + continue + wilson = _wilson_for( + int(c.get("useful_count") or 0), + int(c.get("times_overlooked") or 0), + int(c.get("times_ignored") or 0), + ) + out.append(RelevantMemory( + kind=str(c.get("kind")), id=str(c.get("id")), text=str(c.get("text") or ""), + polarity=c.get("polarity"), confidence=c.get("confidence"), + useful_count=int(c.get("useful_count") or 0), + age_days=_age_days(c.get("updated_at"), _now), + hits=0, score=wilson, reason=reason, + )) + out.sort(key=lambda m: (-m.score, m.id)) + return out + + _TEXT_MAX_CHARS = 400 _BLOCK_HEADER = ( @@ -330,5 +374,7 @@ def format_relevant(items: list[RelevantMemory]) -> str: text = f"Known pitfall -- do this instead: {text}" lines.append(f"{i}. {_meta_tag(m)}") lines.append(f" {text}") + if m.reason: + lines.append(f" Triggered by: {m.reason}") lines.append(_BLOCK_FOOTER) return "\n".join(lines) diff --git a/better_memory/setup/manifest.py b/better_memory/setup/manifest.py index 02805f3..18e0a2c 100644 --- a/better_memory/setup/manifest.py +++ b/better_memory/setup/manifest.py @@ -81,10 +81,9 @@ class HookSpec: "better_memory.hooks.contextual_inject", "UserPromptSubmit", None, False, True ), # Matcher is None (unscoped = all tools) rather than a tool-name - # alternation: the hook's per-session PreToolUse latch (SeenStore - # .pretool_fired/.mark_pretool_fired) makes an unscoped matcher cheap — - # only the first PreToolUse event per session does real work; every - # later one short-circuits on the state file before touching the DB. + # alternation: PreToolUse is the trigger channel and must see every + # tool call (memories declare which tools/skills/paths they fire on). + # Measured cost per call is interpreter start, not the trigger match. HookSpec( "better_memory.hooks.contextual_inject", "PreToolUse", None, False, True ), diff --git a/tests/db/test_migration_0020_exposure_source_trigger.py b/tests/db/test_migration_0020_exposure_source_trigger.py new file mode 100644 index 0000000..e9eb6c0 --- /dev/null +++ b/tests/db/test_migration_0020_exposure_source_trigger.py @@ -0,0 +1,88 @@ +"""Migration 0020: exposure source CHECK widened to include 'trigger', +table rebuilt with every later column and both indexes preserved.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from better_memory.db.connection import connect +from better_memory.db.schema import apply_migrations + +_MIGRATIONS = Path(__file__).parent.parent.parent / "better_memory" / "db" / "migrations" + + +@pytest.fixture +def conn(tmp_memory_db: Path): + c = connect(tmp_memory_db) + apply_migrations(c) + try: + yield c + finally: + c.close() + + +def test_trigger_source_accepted(conn): + conn.execute( + "INSERT INTO session_memory_exposure " + "(session_id, memory_kind, memory_id, exposed_at, source) " + "VALUES ('s1', 'semantic', 'm1', '2026-10-05T00:00:00+00:00', 'trigger')" + ) + row = conn.execute( + "SELECT source FROM session_memory_exposure WHERE session_id='s1'" + ).fetchone() + assert row["source"] == "trigger" + + +def test_unknown_source_still_rejected(conn): + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO session_memory_exposure " + "(session_id, memory_kind, memory_id, exposed_at, source) " + "VALUES ('s1', 'reflection', 'r1', '2026-10-05T00:00:00+00:00', 'bogus')" + ) + + +def test_columns_pk_and_indexes_preserved(conn): + info = conn.execute("PRAGMA table_info(session_memory_exposure)").fetchall() + assert {r["name"] for r in info} == { + "session_id", "memory_kind", "memory_id", "exposed_at", "source", "rated_at", + "classification", "via_exploration", "evidence", "display", + } + pk = [r["name"] for r in sorted((r for r in info if r["pk"] > 0), key=lambda r: r["pk"])] + assert pk == ["session_id", "memory_kind", "memory_id", "exposed_at"] + names = { + r["name"] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='index' " + "AND tbl_name='session_memory_exposure'" + ).fetchall() + } + assert {"idx_sme_session_unrated", "idx_sme_memory"} <= names + + +def test_existing_rows_survive_rebuild(tmp_memory_db: Path): + """Apply up to 0019, insert a fully populated row, then apply 0020.""" + c = connect(tmp_memory_db) + try: + import shutil + import tempfile + with tempfile.TemporaryDirectory() as td: + partial = Path(td) + for f in sorted(_MIGRATIONS.glob("[0-9][0-9][0-9][0-9]_*.sql")): + if f.name < "0020": + shutil.copy(f, partial / f.name) + apply_migrations(c, migrations_dir=partial) + c.execute( + "INSERT INTO session_memory_exposure (session_id, memory_kind, memory_id, " + "exposed_at, source, rated_at, classification, via_exploration, evidence, display) " + "VALUES ('s', 'reflection', 'r', '2026-01-01', 'contextual', '2026-01-02', " + "'shaped', 1, 'used it', 'title')" + ) + c.commit() + apply_migrations(c) + row = c.execute("SELECT * FROM session_memory_exposure").fetchone() + assert (row["source"], row["classification"], row["via_exploration"], + row["evidence"], row["display"]) == ("contextual", "shaped", 1, "used it", "title") + finally: + c.close() diff --git a/tests/hooks/test_contextual_inject.py b/tests/hooks/test_contextual_inject.py index 5623c7d..6548c29 100644 --- a/tests/hooks/test_contextual_inject.py +++ b/tests/hooks/test_contextual_inject.py @@ -55,23 +55,53 @@ def _seed_reflection( home: Path, rid: str, *, title: str, use_cases: str = "context", hints: list[str] | None = None, useful_count: int = 0, confidence: float = 0.8, polarity: str = "do", + triggers: list[str] | None = None, ) -> None: conn = connect(home / "memory.db") try: conn.execute( """INSERT INTO reflections (id, title, project, phase, polarity, use_cases, hints, - confidence, created_at, updated_at, useful_count) + confidence, created_at, updated_at, useful_count, triggers) VALUES (?, ?, ?, 'general', ?, ?, ?, ?, '2026-01-01', - '2026-01-01', ?)""", + '2026-01-01', ?, ?)""", (rid, title, _PROJECT, polarity, use_cases, - json.dumps(hints or []), confidence, useful_count), + json.dumps(hints or []), confidence, useful_count, + json.dumps(triggers) if triggers else None), ) conn.commit() finally: conn.close() +def _seed_semantic( + home: Path, sid: str, *, content: str, triggers: list[str] | None = None, +) -> None: + conn = connect(home / "memory.db") + try: + conn.execute( + """INSERT INTO semantic_memories + (id, content, project, scope, created_at, updated_at, triggers) + VALUES (?, ?, ?, 'project', '2026-01-01', '2026-01-01', ?)""", + (sid, content, _PROJECT, json.dumps(triggers) if triggers else None), + ) + conn.commit() + finally: + conn.close() + + +def _exposure_sources(home: Path, session_id: str) -> dict[str, str]: + conn = connect(home / "memory.db") + try: + rows = conn.execute( + "SELECT memory_id, source FROM session_memory_exposure WHERE session_id = ?", + (session_id,), + ).fetchall() + finally: + conn.close() + return {r["memory_id"]: r["source"] for r in rows} + + def _diag_value(home: Path, metric: str) -> int | None: conn = connect(home / "memory.db") try: @@ -362,52 +392,6 @@ def _raise(*args, **kwargs): assert "refl-widget-deploy-6" in ctx -def test_pretool_fires_once_per_session(bm_home, monkeypatch, capsys): - """Second PreToolUse in the same session hits the latch: empty - additionalContext, and contextual_fired_pretool is bumped only once.""" - _seed_reflection(bm_home, "refl-widget-deploy-7", title="widget deploy playbook") - payload = { - "hook_event_name": "PreToolUse", "tool_name": "Skill", - "tool_input": {"skill": "deploy the widget service now"}, - "cwd": ".", "session_id": "sess-7", - } - first = _run(payload, monkeypatch, capsys) - assert first["hookSpecificOutput"]["additionalContext"] != "" - assert _diag_value(bm_home, "contextual_fired_pretool") == 1 - - second = _run(payload, monkeypatch, capsys) - assert second["hookSpecificOutput"]["additionalContext"] == "" - assert _diag_value(bm_home, "contextual_fired_pretool") == 1 - - -def test_userprompt_unaffected_by_pretool_latch(bm_home, monkeypatch, capsys): - """The PreToolUse latch must not suppress UserPromptSubmit injections, - even after a PreToolUse event has already fired in the same session. - - The PreToolUse payload deliberately shares no evidence with the seeded - reflection (no BM25/vec/keyword match) so it latches without injecting or - marking anything seen -- isolating the latch from the unrelated - SeenStore dedup mechanism, which would otherwise also explain a second - empty result. - """ - _seed_reflection(bm_home, "refl-widget-deploy-8", title="widget deploy playbook") - session_id = "sess-8" - pretool_payload = { - "hook_event_name": "PreToolUse", "tool_name": "Bash", - "tool_input": {"command": "zebra flamingo unrelated topic"}, - "cwd": ".", "session_id": session_id, - } - first = _run(pretool_payload, monkeypatch, capsys) # fires and latches PreToolUse - assert first["hookSpecificOutput"]["additionalContext"] == "" - - prompt_payload = { - "hook_event_name": "UserPromptSubmit", "prompt": "deploy the widget service now", - "cwd": ".", "session_id": session_id, - } - res = _run(prompt_payload, monkeypatch, capsys) - assert res["hookSpecificOutput"]["additionalContext"] != "" - - @pytest.mark.parametrize("text,human", [ ("fix the bug", True), (" x", False), @@ -447,3 +431,97 @@ def test_peer_message_is_suppressed(bm_home, monkeypatch, capsys): monkeypatch, capsys, ) assert "refl-widget-deploy-peer" in res["hookSpecificOutput"]["additionalContext"] + + +_HEREDOC = {"command": "cat > notes.md <<'EOF'\nhello\nEOF"} + + +def test_pretool_trigger_hit_injects_with_reason(bm_home, monkeypatch, capsys): + _seed_semantic(bm_home, "sem-heredoc", content="Use the Write tool for large files", + triggers=["bash:<<"]) + res = _run( + {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": _HEREDOC, + "cwd": ".", "session_id": "sess-t1"}, + monkeypatch, capsys, + ) + ctx = res["hookSpecificOutput"]["additionalContext"] + assert "sem-heredoc" in ctx + assert "Triggered by: bash:<<" in ctx + assert _exposure_sources(bm_home, "sess-t1") == {"sem-heredoc": "trigger"} + assert _diag_value(bm_home, "trigger_fired") == 1 + assert _diag_value(bm_home, "trigger_injected") == 1 + assert _diag_value(bm_home, "contextual_fired_pretool") == 1 + + +def test_pretool_no_trigger_no_exposure(bm_home, monkeypatch, capsys): + _seed_semantic(bm_home, "sem-heredoc", content="Use the Write tool for large files", + triggers=["bash:<<"]) + # Keyword overlap with the tool input must NOT serve a memory: the + # PreToolUse channel is trigger-only. + _seed_reflection(bm_home, "refl-read-files", title="read files playbook") + res = _run( + {"hook_event_name": "PreToolUse", "tool_name": "Read", + "tool_input": {"file_path": "read files playbook.md"}, + "cwd": ".", "session_id": "sess-t2"}, + monkeypatch, capsys, + ) + assert res["hookSpecificOutput"]["additionalContext"] == "" + assert _exposure_sources(bm_home, "sess-t2") == {} + assert _diag_value(bm_home, "trigger_fired") == 1 + assert _diag_value(bm_home, "trigger_injected") == 0 + + +def test_pretool_fires_on_every_call(bm_home, monkeypatch, capsys): + _seed_semantic(bm_home, "sem-heredoc", content="heredoc pitfall", triggers=["bash:<<"]) + _seed_reflection(bm_home, "refl-webfetch", title="WebFetch refusals", + triggers=["tool:WebFetch"]) + first = _run( + {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": _HEREDOC, + "cwd": ".", "session_id": "sess-t3"}, + monkeypatch, capsys, + ) + assert "sem-heredoc" in first["hookSpecificOutput"]["additionalContext"] + second = _run( + {"hook_event_name": "PreToolUse", "tool_name": "WebFetch", + "tool_input": {"url": "https://example.com"}, "cwd": ".", "session_id": "sess-t3"}, + monkeypatch, capsys, + ) + assert "refl-webfetch" in second["hookSpecificOutput"]["additionalContext"] + assert _exposure_sources(bm_home, "sess-t3") == { + "sem-heredoc": "trigger", "refl-webfetch": "trigger", + } + assert _diag_value(bm_home, "contextual_fired_pretool") == 2 + + +def test_pretool_seen_store_dedups_trigger(bm_home, monkeypatch, capsys): + _seed_semantic(bm_home, "sem-heredoc", content="heredoc pitfall", triggers=["bash:<<"]) + payload = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": _HEREDOC, + "cwd": ".", "session_id": "sess-t4"} + first = _run(payload, monkeypatch, capsys)["hookSpecificOutput"]["additionalContext"] + assert "sem-heredoc" in first + assert _run(payload, monkeypatch, capsys)["hookSpecificOutput"]["additionalContext"] == "" + assert _diag_value(bm_home, "contextual_suppressed_dedup") == 1 + + +def test_prompt_then_trigger_same_memory_once(bm_home, monkeypatch, capsys): + _seed_semantic(bm_home, "sem-heredoc", content="Bash heredoc pitfall on windows", + triggers=["bash:<<"]) + prompt = {"hook_event_name": "UserPromptSubmit", "prompt": "the bash heredoc pitfall again", + "cwd": ".", "session_id": "sess-t5"} + first = _run(prompt, monkeypatch, capsys)["hookSpecificOutput"]["additionalContext"] + assert "sem-heredoc" in first + tool = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": _HEREDOC, + "cwd": ".", "session_id": "sess-t5"} + assert _run(tool, monkeypatch, capsys)["hookSpecificOutput"]["additionalContext"] == "" + assert _exposure_sources(bm_home, "sess-t5") == {"sem-heredoc": "contextual"} + + +def test_pretool_mode_userprompt_only_disables_triggers(bm_home, monkeypatch, capsys): + _seed_semantic(bm_home, "sem-heredoc", content="heredoc pitfall", triggers=["bash:<<"]) + res = _run( + {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": _HEREDOC, + "cwd": ".", "session_id": "sess-t6"}, + monkeypatch, capsys, mode="userprompt", + ) + assert res["hookSpecificOutput"]["additionalContext"] == "" + assert _exposure_sources(bm_home, "sess-t6") == {} diff --git a/tests/services/test_context_seen.py b/tests/services/test_context_seen.py index 8df46a2..389aecb 100644 --- a/tests/services/test_context_seen.py +++ b/tests/services/test_context_seen.py @@ -66,38 +66,21 @@ def test_missing_state_dir_never_raises(tmp_path): s.mark_seen([("reflection", "r1")]) -class TestPretoolLatch: - def test_defaults_false_then_persists(self, tmp_path): - s = SeenStore(tmp_path, "sess") - assert s.pretool_fired() is False - s.mark_pretool_fired() - assert SeenStore(tmp_path, "sess").pretool_fired() is True - - def test_corrupt_state_means_not_fired(self, tmp_path): - (tmp_path / "context_seen_sess.json").write_text("{", encoding="utf-8") - assert SeenStore(tmp_path, "sess").pretool_fired() is False - - def test_try_claim_pretool_fired_only_first_caller_wins(self, tmp_path): - # #107: PreToolUse "one real firing per session" was a check-then-set - # across two file operations, so N parallel hook processes could all - # observe pretool_fired == False and all proceed. The sentinel-based - # atomic claim guarantees exactly one True return. - stores = [SeenStore(tmp_path, "sess") for _ in range(4)] - wins = [s.try_claim_pretool_fired() for s in stores] - assert wins.count(True) == 1 - assert wins.count(False) == 3 - # And every subsequent instance sees the latch as fired. - assert SeenStore(tmp_path, "sess").pretool_fired() is True - - def test_prune_stale_removes_pretool_sentinel(self, tmp_path): - import os - SeenStore(tmp_path, "sess").mark_pretool_fired() - sentinel = tmp_path / "context_seen_sess.pretool" - assert sentinel.exists() - ten_days_ago = datetime(2026, 7, 1, tzinfo=UTC).timestamp() - os.utime(sentinel, (ten_days_ago, ten_days_ago)) - prune_stale(tmp_path, now=datetime(2026, 7, 11, tzinfo=UTC)) - assert not sentinel.exists() +def test_prune_stale_removes_legacy_pretool_sentinel(tmp_path): + """Older versions left a ``.pretool`` latch file per session; prune + still sweeps them so upgraded installs do not accumulate stale files.""" + import os + sentinel = tmp_path / "context_seen_sess.pretool" + sentinel.write_text("", encoding="utf-8") + ten_days_ago = datetime(2026, 7, 1, tzinfo=UTC).timestamp() + os.utime(sentinel, (ten_days_ago, ten_days_ago)) + prune_stale(tmp_path, now=datetime(2026, 7, 11, tzinfo=UTC)) + assert not sentinel.exists() + + +def test_seen_store_has_no_pretool_latch(): + assert not hasattr(SeenStore, "try_claim_pretool_fired") + assert not hasattr(SeenStore, "pretool_fired") class TestConcurrentMutators: diff --git a/tests/services/test_relevant_format.py b/tests/services/test_relevant_format.py index 72c372b..cee48a8 100644 --- a/tests/services/test_relevant_format.py +++ b/tests/services/test_relevant_format.py @@ -53,3 +53,9 @@ def test_missing_age_omitted(): def test_output_is_ascii(): out = format_relevant([_mem()]) out.encode("ascii") # raises if any non-ASCII slipped in + + +def test_reason_line_rendered_only_when_set(): + out = format_relevant([_mem(reason="bash:<<")]) + assert " Triggered by: bash:<<" in out + assert "Triggered by" not in format_relevant([_mem()]) From bb8f16a9fc56df4ec6061d5e941a13fcd0fbb80c Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:17:52 +0100 Subject: [PATCH 08/16] feat(bootstrap): deferred mode renders the index line only Co-Authored-By: Claude Fable 5.1 --- better_memory/services/session_bootstrap.py | 27 ++++------ tests/services/test_session_bootstrap.py | 57 +++++++++++++-------- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/better_memory/services/session_bootstrap.py b/better_memory/services/session_bootstrap.py index 2ed2c01..8e22b71 100644 --- a/better_memory/services/session_bootstrap.py +++ b/better_memory/services/session_bootstrap.py @@ -260,8 +260,12 @@ def bootstrap( } if get_config().inject_mode == "deferred": - general_only = [m for m in semantic if m.scope == "general"] - deferred_now = self._clock() + # Deferred renders NO memories at session start -- not even + # general-scope rules. Dumped at bootstrap they were rated + # ignored in 29 of 34 rated serves; they now arrive through the + # prompt gate and the tool-call trigger channel (spec + # 2026-10-05-just-in-time-serving-design.md §1). With nothing + # shown there is nothing to expose, so no ledger rows either. n_refl = sum(reflections_counts.values()) n_sem = semantic_count index_line = ( @@ -276,23 +280,10 @@ def bootstrap( action=action, episode_id=episode_id, ), + index_line, + "---", + _FOOTER, ] - deferred_sem_section, deferred_semantic_ids = _render_semantic_full( - general_only, deferred_now, - ) - if deferred_sem_section: - deferred_sections.append(deferred_sem_section) - deferred_sections.append(index_line) - deferred_sections.append("---") - deferred_sections.append(_FOOTER) - - self._record_exposure( - session_id=session_id, - reflection_ids=[], - semantic_ids=deferred_semantic_ids, - reflection_display={}, - semantic_display={m.id: m.content for m in general_only}, - ) return BootstrapResult( additional_context="\n\n".join(deferred_sections), diff --git a/tests/services/test_session_bootstrap.py b/tests/services/test_session_bootstrap.py index 49b9931..f7dbdc1 100644 --- a/tests/services/test_session_bootstrap.py +++ b/tests/services/test_session_bootstrap.py @@ -563,9 +563,12 @@ def test_list_session_exposures_empty_session_id_returns_none_envelope(conn) -> class TestDeferredBootstrap: - def test_deferred_renders_general_semantics_and_index_only( + def test_deferred_renders_index_only( self, conn, git_repo: Path, monkeypatch ) -> None: + """Deferred mode dumps nothing at session start -- not even + general-scope rules (they were 29 of 51 ignored ratings when + dumped); memories arrive via the prompt and trigger channels.""" monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "deferred") proj = git_repo.name @@ -580,43 +583,57 @@ def test_deferred_renders_general_semantics_and_index_only( ) svc = SessionBootstrapService(conn) - text = svc.bootstrap( + result = svc.bootstrap( source="startup", session_id="sess-deferred-1", cwd=git_repo, - ).additional_context + ) + text = result.additional_context - assert "general-fact-one" in text - assert "general-fact-two" in text + assert "### Semantic memories" not in text + assert "general-fact-one" not in text assert "project-fact-one" not in text - assert "project-fact-two" not in text - assert "project-fact-three" not in text assert "refl-title-0" not in text assert "knows 4 reflections + 5 semantic memories" in text + assert "## better-memory: session bootstrap" in text + assert "memory_credit" in text + # Pool sizes are still reported on the result. + assert result.semantic_count == 5 + assert sum(result.reflections_counts.values()) == 4 - def test_deferred_exposes_only_general_semantics( + def test_deferred_writes_no_exposures( self, conn, git_repo: Path, monkeypatch ) -> None: monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "deferred") proj = git_repo.name - gen_ids = [ - _seed_semantic(conn, content="general-a", project="anyproj", scope="general"), - _seed_semantic(conn, content="general-b", project="otherproj", scope="general"), - ] + _seed_semantic(conn, content="general-a", project="anyproj", scope="general") + _seed_semantic(conn, content="general-b", project="otherproj", scope="general") _seed_semantic(conn, content="proj-a", project=proj, scope="project") - _seed_semantic(conn, content="proj-b", project=proj, scope="project") _seed_reflection(conn, project=proj, polarity="do", scope="project") svc = SessionBootstrapService(conn) svc.bootstrap(source="startup", session_id="sess-deferred-2", cwd=git_repo) - rows = conn.execute( - "SELECT memory_kind, memory_id, source FROM session_memory_exposure " - "WHERE session_id = ?", + n = conn.execute( + "SELECT COUNT(*) FROM session_memory_exposure WHERE session_id = ?", ("sess-deferred-2",), - ).fetchall() - exposed = {(r["memory_kind"], r["memory_id"]) for r in rows} - assert exposed == {("semantic", gid) for gid in gen_ids} - assert all(r["source"] == "bootstrap" for r in rows) + ).fetchone()[0] + assert n == 0 + + def test_legacy_still_dumps_general_semantics( + self, conn, git_repo: Path, monkeypatch + ) -> None: + monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "legacy") + _seed_semantic(conn, content="general-fact-one", project="anyproj", scope="general") + svc = SessionBootstrapService(conn) + text = svc.bootstrap( + source="startup", session_id="sess-legacy-1", cwd=git_repo, + ).additional_context + assert "general-fact-one" in text + n = conn.execute( + "SELECT COUNT(*) FROM session_memory_exposure WHERE session_id = ?", + ("sess-legacy-1",), + ).fetchone()[0] + assert n == 1 def test_unset_mode_is_deferred_byte_identical( self, tmp_path: Path, git_repo: Path, monkeypatch From b5888402632333a9a69d963bf46c9b08a84a5f7e Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:21:41 +0100 Subject: [PATCH 09/16] feat(mcp): triggers parameter on semantic observe and update Co-Authored-By: Claude Fable 5.1 --- better_memory/mcp/handlers/semantics.py | 33 +++++++++-- better_memory/mcp/tools.py | 24 +++++++- tests/mcp/test_handlers_remote.py | 2 + tests/mcp/test_semantic_tools.py | 76 +++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/better_memory/mcp/handlers/semantics.py b/better_memory/mcp/handlers/semantics.py index 8e18eab..b97ce4a 100644 --- a/better_memory/mcp/handlers/semantics.py +++ b/better_memory/mcp/handlers/semantics.py @@ -16,6 +16,17 @@ from better_memory.storage import StorageBackend +def _triggers_arg(args: dict[str, Any]) -> list[str] | None: + """``triggers`` from the tool args: None when absent/null, else a list of + str (validated downstream by services.triggers.validate_triggers).""" + raw = args.get("triggers") + if raw is None: + return None + if not isinstance(raw, list) or not all(isinstance(t, str) for t in raw): + raise ValueError("triggers must be a list of strings") + return raw + + class SemanticToolHandlers: """User-stated facts/preferences CRUD. @@ -47,11 +58,13 @@ async def semantic_observe(self, args: dict[str, Any]) -> list[TextContent]: # against MCP clients sending {"scope": null} — dict.get returns the # default only when the key is absent, not when its value is None. # Same fix as PR #25's BugBot finding on memory.observe. + triggers = _triggers_arg(args) if self._remote is not None: memory_id = self._remote.semantic_observe( content=args["content"], project=project, scope=args.get("scope") or "project", + triggers=triggers, ) return [ TextContent(type="text", text=json.dumps({"id": memory_id})) @@ -60,6 +73,7 @@ async def semantic_observe(self, args: dict[str, Any]) -> list[TextContent]: content=args["content"], project=project, scope=args.get("scope") or "project", + triggers=triggers, ) return [TextContent(type="text", text=json.dumps({"id": memory_id}))] @@ -90,6 +104,7 @@ async def semantic_retrieve(self, args: dict[str, Any]) -> list[TextContent]: "scope": record.scope, "created_at": None, "updated_at": None, + "triggers": list(getattr(record, "triggers", []) or []), } for record in self._remote.semantic_list( project=project, scope_filter=None @@ -105,18 +120,28 @@ async def semantic_retrieve(self, args: dict[str, Any]) -> list[TextContent]: "scope": m.scope, "created_at": m.created_at, "updated_at": m.updated_at, + "triggers": list(getattr(m, "triggers", []) or []), } for m in memories ] return [TextContent(type="text", text=json.dumps(payload))] async def semantic_update(self, args: dict[str, Any]) -> list[TextContent]: + """Edit content and/or triggers. At least one must be given.""" + content = args.get("content") + triggers = _triggers_arg(args) + if content is None and triggers is None: + raise ValueError("provide content and/or triggers") if self._remote is not None: - self._remote.semantic_update_text( - id=args["id"], content=args["content"] - ) + if content is not None: + self._remote.semantic_update_text(id=args["id"], content=content) + if triggers is not None: + self._remote.set_triggers(kind="semantic", id=args["id"], triggers=triggers) return [TextContent(type="text", text=json.dumps({"ok": True}))] - self._semantic.update_text(id=args["id"], content=args["content"]) + if content is not None: + self._semantic.update_text(id=args["id"], content=content) + if triggers is not None: + self._semantic.set_triggers(id=args["id"], triggers=triggers) return [TextContent(type="text", text=json.dumps({"ok": True}))] async def semantic_delete(self, args: dict[str, Any]) -> list[TextContent]: diff --git a/better_memory/mcp/tools.py b/better_memory/mcp/tools.py index 5c93301..fbd3b52 100644 --- a/better_memory/mcp/tools.py +++ b/better_memory/mcp/tools.py @@ -97,6 +97,15 @@ def tool_definitions( "'general' for cross-project workflow rules." ), }, + "triggers": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Tool-call triggers that serve this memory just in " + "time: tool:, skill:, " + "bash:, path:, write:large." + ), + }, }, }, ), @@ -124,15 +133,26 @@ def tool_definitions( Tool( name="memory.semantic_update", description=( - "Edit a semantic memory's content in place. Bumps updated_at." + "Edit a semantic memory's content and/or its tool-call " + "triggers in place. Give content, triggers, or both. " + "Bumps updated_at." ), inputSchema={ "type": "object", - "required": ["id", "content"], + "required": ["id"], "additionalProperties": False, "properties": { "id": {"type": "string"}, "content": {"type": "string"}, + "triggers": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Tool-call triggers that serve this memory just in " + "time: tool:, skill:, " + "bash:, path:, write:large." + ), + }, }, }, ), diff --git a/tests/mcp/test_handlers_remote.py b/tests/mcp/test_handlers_remote.py index 6af0ce9..e468291 100644 --- a/tests/mcp/test_handlers_remote.py +++ b/tests/mcp/test_handlers_remote.py @@ -313,7 +313,9 @@ async def test_semantic_retrieve_passes_through_backend_merged_list_with_stable_ for row in rows: assert set(row) == { "id", "content", "project", "scope", "created_at", "updated_at", + "triggers", } + assert row["triggers"] == [] assert row["project"] is None assert row["created_at"] is None assert row["updated_at"] is None diff --git a/tests/mcp/test_semantic_tools.py b/tests/mcp/test_semantic_tools.py index 75d56ef..e8548d5 100644 --- a/tests/mcp/test_semantic_tools.py +++ b/tests/mcp/test_semantic_tools.py @@ -138,3 +138,79 @@ def test_delete_missing_is_noop(self, conn): from better_memory.services.semantic import SemanticMemoryService svc = SemanticMemoryService(conn) svc.delete(id="ghost") + + +class TestSemanticTriggers: + """Handler-level: ``triggers`` on observe and update, echoed by retrieve.""" + + def _handlers(self, conn): + from better_memory.mcp.handlers.semantics import SemanticToolHandlers + from better_memory.services.semantic import SemanticMemoryService + return SemanticToolHandlers(semantic=SemanticMemoryService(conn)) + + async def test_observe_with_triggers_persists(self, conn, monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_PROJECT", "proj-a") + h = self._handlers(conn) + out = await h.semantic_observe( + {"content": "rule", "triggers": ["tool:WebFetch", "bash:<<"]} + ) + memory_id = _json.loads(out[0].text)["id"] + row = conn.execute( + "SELECT triggers FROM semantic_memories WHERE id = ?", (memory_id,), + ).fetchone() + assert _json.loads(row["triggers"]) == ["tool:WebFetch", "bash:<<"] + + async def test_observe_rejects_bad_trigger(self, conn, monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_PROJECT", "proj-a") + h = self._handlers(conn) + with pytest.raises(ValueError, match="invalid trigger"): + await h.semantic_observe({"content": "rule", "triggers": ["nope"]}) + assert conn.execute("SELECT COUNT(*) FROM semantic_memories").fetchone()[0] == 0 + + async def test_update_triggers_only_keeps_content(self, conn, monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_PROJECT", "proj-a") + h = self._handlers(conn) + memory_id = _json.loads( + (await h.semantic_observe({"content": "rule"}))[0].text + )["id"] + out = await h.semantic_update({"id": memory_id, "triggers": ["skill:superpowers:*"]}) + assert _json.loads(out[0].text) == {"ok": True} + row = conn.execute( + "SELECT content, triggers FROM semantic_memories WHERE id = ?", (memory_id,), + ).fetchone() + assert row["content"] == "rule" + assert _json.loads(row["triggers"]) == ["skill:superpowers:*"] + + async def test_update_content_and_triggers_together(self, conn, monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_PROJECT", "proj-a") + h = self._handlers(conn) + memory_id = _json.loads( + (await h.semantic_observe({"content": "rule"}))[0].text + )["id"] + await h.semantic_update({"id": memory_id, "content": "rule v2", "triggers": []}) + row = conn.execute( + "SELECT content, triggers FROM semantic_memories WHERE id = ?", (memory_id,), + ).fetchone() + assert (row["content"], row["triggers"]) == ("rule v2", None) + + async def test_update_requires_content_or_triggers(self, conn): + h = self._handlers(conn) + with pytest.raises(ValueError, match="provide content and/or triggers"): + await h.semantic_update({"id": "x"}) + + async def test_retrieve_includes_triggers(self, conn, monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_PROJECT", "proj-a") + h = self._handlers(conn) + await h.semantic_observe({"content": "rule", "triggers": ["tool:Agent"]}) + rows = _json.loads((await h.semantic_retrieve({"project": "proj-a"}))[0].text) + assert rows[0]["triggers"] == ["tool:Agent"] + + def test_schema_declares_triggers(self): + from better_memory.mcp.server import _tool_definitions + tools = {t.name: t for t in _tool_definitions()} + for name in ("memory.semantic_observe", "memory.semantic_update"): + props = tools[name].inputSchema["properties"] + assert props["triggers"]["type"] == "array" + assert props["triggers"]["items"] == {"type": "string"} + assert "tool:" in props["triggers"]["description"] + assert tools["memory.semantic_update"].inputSchema["required"] == ["id"] From ec9111e09a31b7e5505cf1fb205eba9f90c4232d Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:25:52 +0100 Subject: [PATCH 10/16] feat(ui): edit memory triggers in the drawers Co-Authored-By: Claude Fable 5.1 --- better_memory/ui/app.py | 29 +++++ better_memory/ui/queries.py | 7 +- .../fragments/reflection_drawer.html | 13 +++ .../fragments/reflection_edit_form.html | 6 ++ .../templates/fragments/semantic_drawer.html | 11 ++ tests/ui/conftest.py | 1 + tests/ui/test_nav_gating.py | 11 ++ tests/ui/test_reflections.py | 68 ++++++++++++ tests/ui/test_semantic.py | 100 ++++++++++++++++++ 9 files changed, 244 insertions(+), 2 deletions(-) diff --git a/better_memory/ui/app.py b/better_memory/ui/app.py index 053e7fe..b351417 100644 --- a/better_memory/ui/app.py +++ b/better_memory/ui/app.py @@ -75,6 +75,17 @@ def create_app( project=project_name(), ) + def _triggers_field(flask_app) -> list[str] | None: + """The posted ``triggers`` textarea as a list (one per line), or + None when the field is absent or the backend has no trigger + storage -- a posted field is then ignored, never an error.""" + if not getattr(flask_app.extensions["backend"], "supports_triggers", False): + return None + raw = request.form.get("triggers") + if raw is None: + return None + return [line.strip() for line in raw.splitlines() if line.strip()] + @app.context_processor def _inject_caps() -> dict[str, object]: b = app.extensions["backend"] @@ -85,6 +96,7 @@ def _inject_caps() -> dict[str, object]: "supports_retention_runs": b.supports_retention_runs, "supports_reflection_review": b.supports_reflection_review, "supports_reflection_text_edit": b.supports_reflection_text_edit, + "supports_triggers": bool(getattr(b, "supports_triggers", False)), }} @app.teardown_appcontext @@ -473,6 +485,18 @@ def reflection_edit_save(id: str) -> tuple[str, int, dict[str, str]]: f"

{escape(str(exc))}

" "" ), 409, {} + triggers_field = _triggers_field(app) + if triggers_field is not None: + try: + app.extensions["backend"].set_triggers( + kind="reflection", id=id, triggers=triggers_field, + ) + except ValueError as exc: + return ( + f'
' + f"

{escape(str(exc))}

" + "
" + ), 400, {} detail = queries.reflection_detail(conn, reflection_id=id) rating_evidence = queries.fetch_rating_evidence( conn, "reflection", id @@ -583,8 +607,13 @@ def semantic_drawer(id: str): @app.post("/semantic//update") def semantic_update(id: str) -> tuple[str, int, dict[str, str]]: content = request.form.get("content", "").strip() + triggers_field = _triggers_field(app) try: app.extensions["backend"].semantic_update_text(id=id, content=content) + if triggers_field is not None: + app.extensions["backend"].set_triggers( + kind="semantic", id=id, triggers=triggers_field, + ) except (ValueError, RuntimeError) as exc: return ( f'
{escape(str(exc))}
', diff --git a/better_memory/ui/queries.py b/better_memory/ui/queries.py index f51a354..a855657 100644 --- a/better_memory/ui/queries.py +++ b/better_memory/ui/queries.py @@ -7,9 +7,10 @@ from __future__ import annotations import sqlite3 -from dataclasses import dataclass +from dataclasses import dataclass, field from better_memory.services.episode import Episode, row_to_episode +from better_memory.services.triggers import parse_triggers @dataclass(frozen=True) @@ -331,6 +332,7 @@ class ReflectionFull: last_misled_at: str | None = None times_overlooked: int = 0 last_overlooked_at: str | None = None + triggers: list[str] = field(default_factory=list) @dataclass(frozen=True) @@ -392,7 +394,7 @@ def reflection_row( "confidence, status, use_cases, hints, evidence_count, scope, " "created_at, updated_at, " "useful_count, last_useful_at, times_misled, last_misled_at, " - "times_overlooked, last_overlooked_at " + "times_overlooked, last_overlooked_at, triggers " "FROM reflections WHERE id = ?", (reflection_id,), ).fetchone() @@ -419,6 +421,7 @@ def reflection_row( last_misled_at=r_row["last_misled_at"], times_overlooked=r_row["times_overlooked"] or 0, last_overlooked_at=r_row["last_overlooked_at"], + triggers=parse_triggers(r_row["triggers"]), ) diff --git a/better_memory/ui/templates/fragments/reflection_drawer.html b/better_memory/ui/templates/fragments/reflection_drawer.html index 3d61a2e..757fff6 100644 --- a/better_memory/ui/templates/fragments/reflection_drawer.html +++ b/better_memory/ui/templates/fragments/reflection_drawer.html @@ -34,6 +34,19 @@

Use cases

{{ detail.reflection.use_cases }}

+ {% if caps.supports_triggers %} +
+

Triggers

+ {% if detail.reflection.triggers %} +
+ {% for t in detail.reflection.triggers %}{{ t }}{% endfor %} +
+ {% else %} +

No triggers: served by the prompt gate only.

+ {% endif %} +
+ {% endif %} +

Hints

{% set hint_list = detail.reflection.hints | decode_hints %} diff --git a/better_memory/ui/templates/fragments/reflection_edit_form.html b/better_memory/ui/templates/fragments/reflection_edit_form.html index 1f7a734..7e2513d 100644 --- a/better_memory/ui/templates/fragments/reflection_edit_form.html +++ b/better_memory/ui/templates/fragments/reflection_edit_form.html @@ -13,6 +13,12 @@

Edit: {{ detail.reflection.title }}

Hints (one per line) + {% if caps.supports_triggers %} + + {% endif %}
+
+

Useful rate by channel

+

useful = cited + shaped; rate = useful / rated. Target: 33% overall + without the useful-per-session figure falling.

+ {% for label, stats in (("All time", useful_all), ("Last " ~ recent_window ~ " rated sessions", useful_recent)) %} +

{{ label }}

+ + + + {% for r in stats.rows %} + + + + + {% endfor %} + + + + + + +
sourceratedusefulignoredmisledrate
{{ r.source }}{{ r.rated }}{{ r.useful }}{{ r.ignored }}{{ r.misled }}{{ '%.0f%%' | format(r.rate * 100) }}
all{{ stats.total.rated }}{{ stats.total.useful }}{{ stats.total.ignored }}{{ stats.total.misled }}{{ '%.0f%%' | format(stats.total.rate * 100) }}
+

Useful per rated session: {{ '%.2f' | format(stats.useful_per_session) }} + over {{ stats.rated_sessions }} session{{ 's' if stats.rated_sessions != 1 else '' }}.

+ {% endfor %} +
+

Rating diagnostics

diff --git a/tests/ui/test_diagnostics_ratings.py b/tests/ui/test_diagnostics_ratings.py index 4111140..cafea6a 100644 --- a/tests/ui/test_diagnostics_ratings.py +++ b/tests/ui/test_diagnostics_ratings.py @@ -217,3 +217,84 @@ def test_overlooked_total_displayed( import re m = re.search(r"overlooked \(total\)\s*
\s*(\d+)", body) assert m is not None and m.group(1) == "3" # 2 + 1 + + +def _seed_rated(conn, sid, mid, source, classification, rated_at): + conn.execute( + """INSERT INTO session_memory_exposure + (session_id, memory_kind, memory_id, exposed_at, source, rated_at, classification) + VALUES (?, 'semantic', ?, ?, ?, ?, ?)""", + (sid, mid, rated_at, source, rated_at, classification), + ) + + +def _seed_ledger(conn): + """Three rated sessions plus one unrated row. + + s1 (oldest): bootstrap ignored, contextual shaped + s2: trigger cited, trigger ignored, retrieve misled + s3 (newest): bootstrap ignored, trigger shaped + """ + _seed_rated(conn, "s1", "m1", "bootstrap", "ignored", "2026-10-01T10:00:00+00:00") + _seed_rated(conn, "s1", "m2", "contextual", "shaped", "2026-10-01T10:00:00+00:00") + _seed_rated(conn, "s2", "m3", "trigger", "cited", "2026-10-02T10:00:00+00:00") + _seed_rated(conn, "s2", "m4", "trigger", "ignored", "2026-10-02T10:00:00+00:00") + _seed_rated(conn, "s2", "m5", "retrieve", "misled", "2026-10-02T10:00:00+00:00") + _seed_rated(conn, "s3", "m6", "bootstrap", "ignored", "2026-10-03T10:00:00+00:00") + _seed_rated(conn, "s3", "m7", "trigger", "shaped", "2026-10-03T10:00:00+00:00") + conn.execute( + "INSERT INTO session_memory_exposure (session_id, memory_kind, memory_id, " + "exposed_at, source) VALUES ('s4', 'semantic', 'm8', '2026-10-04', 'trigger')" + ) + conn.commit() + + +class TestUsefulRateByChannel: + def test_all_time_rows_total_and_guard(self, conn): + from better_memory.ui.queries import useful_rate_by_channel + _seed_ledger(conn) + out = useful_rate_by_channel(conn, last_n_sessions=None) + rows = {r["source"]: r for r in out["rows"]} + assert rows["bootstrap"] == { + "source": "bootstrap", "rated": 2, "useful": 0, "ignored": 2, "misled": 0, + "rate": 0.0, + } + assert rows["trigger"] == { + "source": "trigger", "rated": 3, "useful": 2, "ignored": 1, "misled": 0, + "rate": pytest.approx(2 / 3), + } + assert rows["retrieve"]["misled"] == 1 and rows["retrieve"]["rate"] == 0.0 + assert out["total"] == { + "source": "all", "rated": 7, "useful": 3, "ignored": 3, "misled": 1, + "rate": pytest.approx(3 / 7), + } + assert out["rated_sessions"] == 3 + assert out["useful_per_session"] == pytest.approx(1.0) + + def test_last_n_sessions_window_drops_oldest(self, conn): + from better_memory.ui.queries import useful_rate_by_channel + _seed_ledger(conn) + out = useful_rate_by_channel(conn, last_n_sessions=2) + sources = {r["source"] for r in out["rows"]} + assert "contextual" not in sources # only in s1 + assert out["total"]["rated"] == 5 + assert out["rated_sessions"] == 2 + + def test_empty_ledger(self, conn): + from better_memory.ui.queries import useful_rate_by_channel + out = useful_rate_by_channel(conn, last_n_sessions=15) + assert out["rows"] == [] + assert out["total"]["rated"] == 0 and out["total"]["rate"] == 0.0 + assert out["rated_sessions"] == 0 and out["useful_per_session"] == 0.0 + + def test_diagnostics_page_renders_panel(self, client, tmp_db): + c = connect(tmp_db) + try: + _seed_ledger(c) + finally: + c.close() + body = client.get("/diagnostics").get_data(as_text=True) + assert "Useful rate by channel" in body + assert "trigger" in body + assert "Useful per rated session" in body + assert "67%" in body From ef71a837aba1ecfb7ea431c3a44d014d4bd6d9b9 Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:31:21 +0100 Subject: [PATCH 12/16] docs: just-in-time serving, triggers and the useful-rate panel Co-Authored-By: Claude Fable 5.1 --- README.md | 13 +++++++------ website/architecture.md | 31 +++++++++++++++++++------------ website/configuration.md | 15 ++++++++------- website/mcp-tools.md | 14 ++++++++------ 4 files changed, 42 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f374753..48065fc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `` / `` 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. @@ -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. | @@ -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 diff --git a/website/architecture.md b/website/architecture.md index d123bd3..3680fd3 100644 --- a/website/architecture.md +++ b/website/architecture.md @@ -43,6 +43,7 @@ The `sqlite-vec` extension is still loaded by every `memory.db` connection (`bet | Closure events | N/A | `CreateEvent(role=OTHER)` from Stop hook; end-of-session rating sweeps additionally emit one best-effort `CreateEvent` (`extractionMode: SKIP`) as a durable, team-visible receipt of what was rated | | Episode tracking | Local `episodes` table | Internal to AgentCore (sessionId) | | Self-rating loop (exposure ledger, `memory.credit`, sweep, Wilson ranking, exploration slot) | Local, entirely in `memory.db` | Same loop — backend-agnostic. Exposure ledger writes to the SAME local `session_memory_exposure` table (session-operational state, never memory content); rating counters (`useful_count`, `ignored_count`, etc.) live on the AWS record and are genuinely shared across teammates. `query`-conditioned retrieval and the contextual-injection evidence gate use server-side semantic search (`RetrieveMemoryRecords`) in place of sqlite's BM25 leg, degrading to Wilson-only / keyword fallback only on an AWS error. | +| Tool-call triggers (`triggers` column, PreToolUse trigger channel, drawer editors) | Supported (`supports_triggers = True`) | Not supported: `supports_triggers = False`, `set_triggers` raises, `triggered_candidates` returns `[]`; the UI hides the trigger editors and the channel is inactive | | Bulk import | N/A (clean start) | `better-memory agentcore migrate` copies existing sqlite reflections + semantic memories into AWS (idempotent, ledgered) | Migrating with `better-memory agentcore migrate` is distinct from activating: it only writes records to AWS and never flips `storage_backend`. A migrated reflection carries its rating counters, `status`, and `source_row_id` in the record's JSON **content body** — the built-in episodic strategy owns the metadata schema — whereas cloud-extracted records (and migrated semantic records) keep that state in record **metadata**. See [AgentCore setup > Migrate](agentcore-setup.md#migrate-existing-memory-optional). @@ -92,24 +93,30 @@ Two distinct retrieve tools, two distinct ranking mechanisms: ## Injection strategies -better-memory gets memory in front of Claude two ways: a dump at session start, and targeted mid-session injection keyed to what Claude is actually doing. +better-memory gets memory in front of Claude through four channels: a session-start index, a typed-prompt channel, a tool-call trigger channel, and explicit retrieval. The design and the measurements behind it are in `docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md`. -**Bootstrap (SessionStart).** Governed by `BETTER_MEMORY_INJECT_MODE` (`legacy` | `deferred`; default `legacy`): +**Bootstrap (SessionStart).** Governed by `BETTER_MEMORY_INJECT_MODE` (`deferred` | `legacy`; default `deferred`, which is also what `better-memory setup` installs): -- **`legacy`** (default, byte-identical to pre-deferred-injection behavior). `SessionBootstrapService.bootstrap` (`better_memory/services/session_bootstrap.py`) renders project-scoped semantic memories and reflections in full only up to `BETTER_MEMORY_BOOTSTRAP_TOP_N` per set (default 5; general-scope semantic memories are always shown in full, uncapped). The remainder collapses into a one-line `### Index (not expanded - retrieve on demand)` section plus a footer affordance pointing at `memory.retrieve` / `memory.retrieve_observations`. Semantic memory ids in the rendered output are the full ids (not truncated), each stamped with an age suffix (`(Nd old)`). Setting `BETTER_MEMORY_BOOTSTRAP_TOP_N=0` disables slimming and renders everything in full. -- **`deferred`**. SessionStart renders only general-scope semantic memories in full, plus a single index line ("better-memory knows N reflections + M semantic memories for this project; relevant ones will surface as you work - or ask via memory_retrieve with a task query"). Project-scoped semantic memories and all reflections are not dumped at session start; they surface exclusively through the contextual channel below, or on demand via `memory.retrieve` / `memory.retrieve_observations`. +- **`deferred`** (default). `SessionBootstrapService.bootstrap` (`better_memory/services/session_bootstrap.py`) renders no memories at all: a header, a single index line ("better-memory knows N reflections + M semantic memories for this project; relevant ones will surface as you work - or ask via memory_retrieve with a task query") and the crediting footer. Nothing is exposed, so nothing is rated. General-scope rules dumped at session start were rated ignored in 29 of 34 rated serves; they now arrive through the two mid-session channels below. +- **`legacy`** (explicit opt-in, byte-identical to the pre-deferred dump). Renders project-scoped semantic memories and reflections in full up to `BETTER_MEMORY_BOOTSTRAP_TOP_N` per set (default 5; general-scope semantic memories always in full), the remainder collapsed into a one-line `### Index (not expanded - retrieve on demand)` section plus a footer affordance pointing at `memory.retrieve` / `memory.retrieve_observations`. Semantic memory ids are full ids with an age suffix (`(Nd old)`). `BETTER_MEMORY_BOOTSTRAP_TOP_N=0` disables slimming. **CLAUDE.md drift sentinel — retired.** SessionStart no longer runs a standalone sentinel that scanned `~/.claude/CLAUDE.md` for stale tool-parameter references (`better_memory/hooks/_claude_md_sentinel.py`, deleted). See [Self-managing wiring](#self-managing-wiring) above: the retrieve/reinforce/synthesize/record protocol text is now a managed block (`manifest.CLAUDE_MD_BLOCK`) that `better-memory setup` / `doctor --fix` / the session-start autocheck (`better_memory.setup.autocheck.maybe_repair`, wired into `hooks/session_bootstrap.py`) keep byte-identical to the canonical text, so drift is repaired outright rather than merely flagged. `better_memory/skills/CLAUDE.snippet.md` (used for non-Claude-Code MCP integrations) remains behavioural instructions only ("pass a task-describing query when you begin a task", "credit with evidence") and never enumerates parameter names or types, so it can't drift the way the retired sentinel used to detect. -**Contextual injection (UserPromptSubmit / PreToolUse).** The `contextual_inject` hook (`better_memory/hooks/contextual_inject.py`) scores the curated memory set (semantic + reflections) against the current prompt (UserPromptSubmit, fires on every prompt) or tool name + input (PreToolUse, matcher now unscoped -- every tool call, not a fixed allowlist) via `retrieve_relevant` (`better_memory/services/relevant.py`), an evidence-gated scorer: +**Typed-prompt channel (UserPromptSubmit).** The `contextual_inject` hook (`better_memory/hooks/contextual_inject.py`) fires on every prompt and first decides whether the prompt is human: text starting with `<` (command output, task notifications, system tags) or with `Another Claude session sent a message` is not, and produces no injection, no exposure and no turn bump (`contextual_suppressed_nonhuman`). The hook payload carries no origin field, so the prefix is the only signal; measured, the hook had fired 571 times across sessions holding 261 typed prompts and 296 peer-agent messages. Human prompts go through `retrieve_relevant` (`better_memory/services/relevant.py`), one evidence gate for both memory kinds: -- A memory injects only when it clears an evidence gate. For reflections: a BM25 match against `reflection_fts` (title / use_cases / hints) -- or, only when that leg is structurally unavailable (no sqlite `conn` passed in), a keyword-hit fallback (>= 2 distinct hits). For semantic memories, which have no FTS substrate of their own: the keyword-hit fallback (>= 2 distinct hits) is the only evidence leg, and it is unconditionally active. No evidence, no injection -- a memory with neither leg present is silently dropped, however popular it is. In agentcore mode, both kinds are instead gated by the backend's own `relevance_ranks` (`StorageBackend.relevance_ranks`, backed by server-side `RetrieveMemoryRecords` semantic search): a memory qualifies iff it appears in that search's result set, falling back to the keyword-hit floor only when the lookup itself fails (an AWS error, signalled by `None` -- a genuinely empty `{}` result is a legitimate negative, not a fallback trigger). -- The Wilson lower-bound prior on rated exposures (see [Self-rating loop](#self-rating-loop)) never qualifies a memory by itself; among qualifiers it only ranks, via reciprocal rank fusion together with the BM25 rank (or, on agentcore, the `relevance_ranks` rank) when present. Popularity forcing irrelevant injections into context was the old failure mode the gate exists to close. -- The keyword-hit fallback described above applies only where the primary evidence leg is structurally unavailable or has failed: for sqlite-backed reflections, when there is no raw sqlite `conn`; for agentcore-backed reflections and semantic memories, only when `relevance_ranks` itself fails (an AWS error on every namespace, signalled by `None` -- a genuinely empty `{}` result is a legitimate negative and is NOT a fallback trigger); for sqlite-backed semantic memories it is unconditional, since they have no other evidence leg at all. `BETTER_MEMORY_CONTEXT_MIN_HITS` is **deprecated**: `contextual_inject` no longer reads it, superseded by the evidence gate above. -- Because PreToolUse now matches every tool, a per-session latch (`SeenStore.pretool_fired` / `mark_pretool_fired`, `better_memory/services/context_seen.py`) runs the full retrieval path at most once per session for PreToolUse; every later PreToolUse event in the session short-circuits on the latch state before touching the DB. UserPromptSubmit is unaffected by the latch and fires on every prompt. -- Survivors are capped to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`, then filtered through a per-session `SeenStore` (`better_memory/services/context_seen.py`, JSON file at `/state/context_seen_.json`) that deduplicates memories already injected this session. `BETTER_MEMORY_CONTEXT_REINJECT_TURNS=0` (default) means a memory is injected at most once per session; a positive value allows re-injection after that many turns have passed since it was last shown. A "turn" here is one firing of the `contextual_inject` hook, not one user prompt-response cycle: each user prompt is a turn, and each PreToolUse latch-firing is a turn too (subsequent latched-out PreToolUse events do not bump the turn counter). -- Survivors render as a `` XML block in `additionalContext`, one entry per item with its kind, id, confidence, `useful_count`, and age, a `dont`-polarity item prefixed `Known pitfall -- do this instead:`, and a footer inviting `memory_credit(kind, id, class, evidence)` — with a one-line evidence statement — when an entry actually helped or misled. -- Survivors are logged to `session_memory_exposure` with `source='contextual'` (best-effort; a write failure never blocks injection) and counted in `rating_diagnostics` (`contextual_fired_userprompt`, `contextual_fired_pretool`, `contextual_injected`, `contextual_suppressed_floor`, `contextual_suppressed_dedup`). These counters are per-firing, not per-item: a firing that injects one or several memories still increments `contextual_injected` by exactly 1. `contextual_suppressed_floor` now means "no candidate cleared the evidence gate", not "below the old keyword-hits floor". +- Tokenise the prompt with `extract_keywords`; drop any token that is *ubiquitous* in the candidate pool (present in more than `DF_CAP = 0.5` of the pool's memories, counted only when the pool has at least `DF_MIN_POOL = 4` memories, otherwise every token is kept); count distinct whole-word hits per memory; a memory qualifies when `hits >= BETTER_MEMORY_CONTEXT_MIN_HITS` (default 2). A single shared token such as "claude" is not evidence: the old any-one-BM25-token qualifier served web-research pitfalls to a repo-exploration prompt because both mentioned "Claude Code". +- The Wilson lower-bound prior on rated exposures (see [Self-rating loop](#self-rating-loop)) never qualifies a memory by itself; among qualifiers it only ranks, via reciprocal rank fusion with a relevance rank (distinct hits descending, ties by BM25 rank over `reflection_fts` where that table exists). +- Agentcore mode is unchanged: the gate is membership in the backend's `relevance_ranks` (server-side `RetrieveMemoryRecords` semantic search), falling back to a keyword-hit floor only when that lookup itself fails (`None`, an AWS error); a genuinely empty `{}` result is a legitimate negative. + +**Trigger channel (PreToolUse).** Fires on every tool call (the old once-per-session latch is gone; the full path measured 184 ms against 175 ms latched, 168 ms of which is interpreter start). It is trigger-only: memories that carry `triggers` (a JSON list on `reflections` and `semantic_memories`, migration 0019) are matched against the tool name and input by the pure matcher in `better_memory/services/triggers.py`; nothing is keyword-matched against tool input, and a memory without triggers is never tool-served. Grammar: `tool:` (exact tool name), `skill:` (the Skill tool with that skill; a trailing `*` matches a prefix), `bash:` (Bash command contains, case-insensitive), `path:` (Write / Edit / Read file path contains, separators normalised to `/`), `write:large` (Write content longer than `BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS`, default 8000). Hits render with a `Triggered by: ` line and are logged with `source='trigger'` (migration 0020 widened the ledger's CHECK); counters `trigger_fired` and `trigger_injected` sit beside `contextual_fired_pretool`. Triggers are set in the management UI drawers (both kinds) or through the `triggers` parameter of `memory.semantic_observe` / `memory.semantic_update`; the storage protocol exposes `supports_triggers`, `set_triggers` and `triggered_candidates`, and agentcore reports `supports_triggers = False` so the channel is inactive there. + +Both channels share the rest of the path: + +- Survivors are filtered through a per-session `SeenStore` (`better_memory/services/context_seen.py`, JSON file at `/state/context_seen_.json`) that deduplicates memories already injected this session by either channel, then capped to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`. `BETTER_MEMORY_CONTEXT_REINJECT_TURNS=0` (default) means a memory is injected at most once per session; a positive value allows re-injection after that many turns. A "turn" is one firing of the hook: each human prompt and each PreToolUse event is a turn. +- Survivors render as a `` XML block in `additionalContext`, one entry per item with its kind, id, confidence, `useful_count` and age, a `dont`-polarity item prefixed `Known pitfall -- do this instead:`, and a footer inviting `memory_credit(kind, id, class, evidence)` with a one-line evidence statement. +- Exposures are written best-effort (a write failure never blocks injection) and counted in `rating_diagnostics` per firing: `contextual_fired_userprompt`, `contextual_fired_pretool`, `contextual_injected`, `trigger_fired`, `trigger_injected`, `contextual_suppressed_floor` (no candidate cleared the gate or no trigger fired), `contextual_suppressed_dedup`, `contextual_suppressed_nonhuman`. + +**Measurement.** The Diagnostics page shows a *Useful rate by channel* panel (`ui/queries.py::useful_rate_by_channel`): per exposure source, rated / useful (cited + shaped) / ignored / misled / rate, all time and over the last 15 rated sessions, plus useful ratings per rated session, the guard against reaching a ratio by serving less. The target set in the design is 33% overall over the first 15 rated sessions after deployment without that guard falling below its baseline of about 1.1. Gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE` (`userprompt` / `pretool` / `both` / `off`). The hook never raises and always exits 0 — failures are swallowed and logged to `hook_errors`, with no `additionalContext` emitted on that turn. diff --git a/website/configuration.md b/website/configuration.md index bb08921..7f7612f 100644 --- a/website/configuration.md +++ b/website/configuration.md @@ -14,12 +14,13 @@ One environment variable roots the runtime filesystem layout. Everything else ha | `BETTER_MEMORY_STORAGE_BACKEND` | unset | `sqlite` or `agentcore`. Explicit **override** of the storage backend. When unset, the backend is read from `$BETTER_MEMORY_HOME/settings.json` (written by `better-memory agentcore init`), falling back to `sqlite`. The env var always wins over settings.json. `agentcore` requires `pip install 'better-memory[agentcore]'` and a populated `agentcore.json` (see [AgentCore setup](agentcore-setup.md)). | | `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. | -| `BETTER_MEMORY_INJECT_MODE` | `legacy` | SessionStart bootstrap strategy: `legacy` (default, byte-identical to pre-deferred-injection behavior -- full bootstrap dump gated only by `BETTER_MEMORY_BOOTSTRAP_TOP_N`) or `deferred` (bootstrap renders only general-scope semantic memories plus a one-line index; project-scoped semantic memories and all reflections surface only through the contextual channel or on-demand `memory.retrieve`). Any value other than `deferred` resolves to `legacy`. See [Architecture](architecture.md#injection-strategies). | +| `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt` (typed-prompt channel only), `pretool` (tool-call trigger channel only), `both` (default), or `off`. See [Architecture](architecture.md#injection-strategies). | +| `BETTER_MEMORY_INJECT_MODE` | `deferred` | SessionStart bootstrap strategy: `deferred` (default, and what `better-memory setup` installs: the bootstrap renders a one-line index only; memories arrive through the typed-prompt and tool-call trigger channels or on-demand `memory.retrieve`) or `legacy` (the original full bootstrap dump, gated by `BETTER_MEMORY_BOOTSTRAP_TOP_N`). Any value other than `legacy` resolves to `deferred`. See [Architecture](architecture.md#injection-strategies). | | `BETTER_MEMORY_BOOTSTRAP_TOP_N` | `5` | Number of project-scoped semantic memories and reflections the SessionStart bootstrap renders in full (beyond that, a one-line index plus a retrieve affordance). `0` disables slimming and dumps everything in full. Only applies in `BETTER_MEMORY_INJECT_MODE=legacy`. | -| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | **Deprecated, unused.** `contextual_inject` no longer reads this. It was the old minimum-keyword-hits floor, superseded by the evidence gate (BM25 match, or a keyword-hit fallback when the BM25 leg is unavailable) described in [Architecture](architecture.md#injection-strategies). Kept only for back-compat resolution of stray env overrides. | -| `BETTER_MEMORY_CONTEXT_MAX_ITEMS` | `3` | Max number of memories `contextual_inject` injects per firing, after the evidence gate and ranking. | -| `BETTER_MEMORY_CONTEXT_REINJECT_TURNS` | `0` | Turns to wait before `contextual_inject` will re-inject a memory already seen this session. `0` means never re-inject (each memory surfaces at most once per session). A "turn" here means one firing of the `contextual_inject` hook, not one user prompt-response cycle: each user prompt counts as a turn, and PreToolUse counts as at most one more turn per session (it's latched to a single real firing -- see [Architecture](architecture.md#injection-strategies)). | +| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | Distinct-hit floor of the typed-prompt evidence gate: a memory qualifies for contextual injection when at least this many distinct, non-ubiquitous prompt tokens appear in it as whole words (both memory kinds). Measured band on 11 rated sessions: 2 gave 43% useful at 75% recall, 3 gave 50% at 42%. See [Architecture](architecture.md#injection-strategies). | +| `BETTER_MEMORY_CONTEXT_MAX_ITEMS` | `3` | Max number of memories `contextual_inject` injects per firing, after the gate (or trigger match), dedup and ranking. | +| `BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS` | `8000` | Content length above which a Write tool call counts as large for the `write:large` trigger. | +| `BETTER_MEMORY_CONTEXT_REINJECT_TURNS` | `0` | Turns to wait before `contextual_inject` will re-inject a memory already seen this session (by either channel). `0` means never re-inject (each memory surfaces at most once per session). A "turn" is one firing of the hook: each human prompt and each PreToolUse event counts. | !!! note "Removed: `BETTER_MEMORY_AGENTCORE_REGION`" The region env var is gone. The AWS region is single-sourced from `agentcore.json` (written by `better-memory agentcore init --region `); a differing env value could only produce a split-brain where events were written to a region the memories don't live in. If you had it exported, remove it — it is ignored. To change region, re-run `init` (see [AgentCore troubleshooting](troubleshooting/agentcore.md)). @@ -72,11 +73,11 @@ The two SQLite files are never shared between processes — the MCP server owns Eight managed hook entries ship with better-memory, across seven hook modules (`contextual_inject` registers on two events), and read or write the filesystem layout above. They are installed automatically by `better-memory setup` (run directly, or via `./scripts/setup.sh` / `.\scripts\setup.ps1`), which merges them idempotently into `~/.claude/settings.json`; the legacy `python -m better_memory.cli.install_hooks` module is a deprecated shim that now just prints a warning and delegates to `setup`. The list below is reference material: -- **`better_memory.hooks.session_bootstrap`** (SessionStart) — opens or reuses a background episode for the session and injects the project's curated context as `additionalContext` for Claude's first turn. Shape depends on `BETTER_MEMORY_INJECT_MODE`: in `legacy` (default) it's project-scoped and general-scope semantic memories plus distilled reflections in `do` / `dont` / `neutral` buckets (retrieved up to 20 per bucket and ranked by a Wilson-score lower bound on positive-rated exposures, then confidence, then recency, with the bucket's last slot reserved for an under-rated memory - fewer than 3 rated exposures - when one exists; see [Architecture](architecture.md#self-rating-loop)), with only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5; general-scope semantic memories are always shown in full) rendered in full and the rest collapsed into a one-line index plus a `memory.retrieve` / `memory.retrieve_observations` affordance (`BETTER_MEMORY_BOOTSTRAP_TOP_N=0` restores the full dump). In `deferred` it's general-scope semantic memories in full plus a single one-line index -- project-scoped semantic memories and reflections surface only through `contextual_inject` or on-demand `memory.retrieve`. Runs in-process against `memory.db` (in agentcore mode it also opens the local `memory.db` — for the exposure ledger only, never memory content — while content reads route through the storage backend instead); failure-isolated: if bootstrap breaks, a fallback directive is injected and the failure is recorded in the `hook_errors` table. Also runs the wiring autocheck (`better_memory.setup.autocheck.maybe_repair`, see `BETTER_MEMORY_WIRING_AUTOCHECK` above) after bootstrap renders, appending at most one summary line — see [Architecture](architecture.md#self-managing-wiring). +- **`better_memory.hooks.session_bootstrap`** (SessionStart) — opens or reuses a background episode for the session and injects the project's curated context as `additionalContext` for Claude's first turn. Shape depends on `BETTER_MEMORY_INJECT_MODE`: in `legacy` (opt-in) it's project-scoped and general-scope semantic memories plus distilled reflections in `do` / `dont` / `neutral` buckets (retrieved up to 20 per bucket and ranked by a Wilson-score lower bound on positive-rated exposures, then confidence, then recency, with the bucket's last slot reserved for an under-rated memory - fewer than 3 rated exposures - when one exists; see [Architecture](architecture.md#self-rating-loop)), with only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5; general-scope semantic memories are always shown in full) rendered in full and the rest collapsed into a one-line index plus a `memory.retrieve` / `memory.retrieve_observations` affordance (`BETTER_MEMORY_BOOTSTRAP_TOP_N=0` restores the full dump). In `deferred` (default) it is a single one-line index and nothing else: memories surface through `contextual_inject` (typed prompts and tool-call triggers) or on-demand `memory.retrieve`. Runs in-process against `memory.db` (in agentcore mode it also opens the local `memory.db` — for the exposure ledger only, never memory content — while content reads route through the storage backend instead); failure-isolated: if bootstrap breaks, a fallback directive is injected and the failure is recorded in the `hook_errors` table. Also runs the wiring autocheck (`better_memory.setup.autocheck.maybe_repair`, see `BETTER_MEMORY_WIRING_AUTOCHECK` above) after bootstrap renders, appending at most one summary line — see [Architecture](architecture.md#self-managing-wiring). - **`better_memory.hooks.observer`** (PostToolUse) — captures tool-call snapshots into `spool/` for later observation creation. - **`better_memory.hooks.session_close`** (Stop) — writes a session-close marker into `spool/`; also emits the rating directive described in [Self-rating loop](architecture.md#self-rating-loop). In agentcore mode (resolved via the env var, else `settings.json`) it additionally fires one `CreateEvent(role=OTHER)` closure event against the current AgentCore session so the episodic strategy extracts within minutes; failure is logged to `hook_errors` and never blocks the marker write. - **`better_memory.hooks.stop_sweep`** (Stop) — a second, independent Stop registration: prints a `systemMessage` reminding Claude to record any non-obvious observations from the session (per the CLAUDE.md mandatory triggers) before stopping. -- **`better_memory.hooks.contextual_inject`** (UserPromptSubmit, every prompt; PreToolUse, now every tool call, latched to one real firing per session) — scores the curated memory set (semantic + reflections) against the current prompt or tool-input through an evidence gate (a BM25 match against `reflection_fts` — replaced on agentcore by the backend's own server-side semantic-search evidence, `relevance_ranks` — or a keyword-hit fallback when those legs are structurally unavailable or an AWS lookup fails; `BETTER_MEMORY_CONTEXT_MIN_HITS` is deprecated and no longer consulted), caps survivors to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`, and injects them as a `` block in `additionalContext`. A per-session seen-file dedups repeats (`BETTER_MEMORY_CONTEXT_REINJECT_TURNS` controls re-injection). Gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE`. See [Architecture](architecture.md#injection-strategies) for detail. +- **`better_memory.hooks.contextual_inject`** (UserPromptSubmit, every prompt; PreToolUse, every tool call) — two channels. Typed prompts (peer-agent messages, command output and system tags are skipped by prefix) pass through a distinct-hit evidence gate: at least `BETTER_MEMORY_CONTEXT_MIN_HITS` distinct, non-ubiquitous prompt tokens must appear in a memory, the same rule for reflections and semantic memories (on agentcore the backend's server-side `relevance_ranks` is the gate instead). Tool calls are matched against each memory's `triggers` (`tool:`, `skill:`, `bash:`, `path:`, `write:large`); a memory without triggers is never tool-served. Survivors are deduped per session, capped to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`, injected as a `` block in `additionalContext`, and logged with source `contextual` or `trigger`. Gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE`. See [Architecture](architecture.md#injection-strategies) for detail. - **`better_memory.hooks.commit_checkpoint`** (PreToolUse, matcher `Bash`, `if: "Bash(git commit*)"`) — fires only immediately before a `git commit` tool call and injects a reminder that CLAUDE.md's mandatory triggers require a `memory.observe` call first if the commit fixes a non-obvious bug, addresses reviewer feedback, or wraps a phase. - **`better_memory.hooks.pre_compact`** (PreCompact) — injects a reminder to persist the in-flight task, key decisions, open questions, and relevant file paths to better-memory via `memory.observe` before context compaction discards them. diff --git a/website/mcp-tools.md b/website/mcp-tools.md index 8ba8644..f869b76 100644 --- a/website/mcp-tools.md +++ b/website/mcp-tools.md @@ -144,10 +144,11 @@ Record a user-stated fact or preference. Distinct from `memory.observe` — sema |---|---|---|---| | `content` | string | yes | The factual statement. | | `scope` | `project` / `general` | optional | `project` (default) for project-scoped rules; `general` for cross-project workflow rules. | +| `triggers` | array of string | optional | Tool-call triggers that serve this memory just in time via the PreToolUse trigger channel: `tool:`, `skill:` (trailing `*` for a prefix), `bash:`, `path:`, `write:large`. Rejected with an error naming the offending string if the grammar is wrong; rejected outright on the agentcore backend. | ### `memory.semantic_retrieve` -Return user-stated facts and preferences for the current project, merged with all general-scope semantic memories. Flat list ordered newest-first. +Return user-stated facts and preferences for the current project, merged with all general-scope semantic memories. Flat list ordered newest-first; each row carries its `triggers` list. | Parameter | Type | Required | Notes | |---|---|---|---| @@ -155,12 +156,13 @@ Return user-stated facts and preferences for the current project, merged with al ### `memory.semantic_update` -Edit a semantic memory's content in place. Bumps `updated_at`. +Edit a semantic memory's content and/or its tool-call triggers in place. Give `content`, `triggers`, or both. Bumps `updated_at`. -| Parameter | Type | Required | -|---|---|---| -| `id` | string | yes | -| `content` | string | yes | +| Parameter | Type | Required | Notes | +|---|---|---|---| +| `id` | string | yes | | +| `content` | string | optional | New text. | +| `triggers` | array of string | optional | Replaces the trigger list (same grammar as `memory.semantic_observe`); an empty list clears it. | ### `memory.semantic_delete` From 3aab4b94d7a0c98bb76b42c511c7005734c78e52 Mon Sep 17 00:00:00 2001 From: emp3thy Date: Mon, 5 Oct 2026 23:50:13 +0100 Subject: [PATCH 13/16] test: pin legacy injection mode in bootstrap-exposure tests Deferred is the default now and exposes nothing at session start; these tests describe the legacy dump's exposure rows. Co-Authored-By: Claude Fable 5.1 --- tests/integration/test_memory_rating_e2e.py | 9 ++++++++- tests/services/test_exposure_tracking.py | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_memory_rating_e2e.py b/tests/integration/test_memory_rating_e2e.py index 18ad891..65dc601 100644 --- a/tests/integration/test_memory_rating_e2e.py +++ b/tests/integration/test_memory_rating_e2e.py @@ -74,7 +74,14 @@ def _seed_semantic(c, sid: str) -> None: # --------------------------------------------------------------------------- def test_full_rating_loop(conn, monkeypatch): - """Full closed loop: bootstrap → retrieve → credit → sweep → verify.""" + """Full closed loop: bootstrap → retrieve → credit → sweep → verify. + + Pinned to legacy injection mode: the loop starts from the five + source='bootstrap' rows the legacy dump writes. Deferred (the default + since just-in-time serving) renders an index line and exposes nothing + at session start. + """ + monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "legacy") T0 = datetime(2026, 5, 11, 12, 0, 0, tzinfo=UTC) T1 = T0 + timedelta(minutes=1) # mid-session retrieve timestamp diff --git a/tests/services/test_exposure_tracking.py b/tests/services/test_exposure_tracking.py index 8eda092..4dfb30f 100644 --- a/tests/services/test_exposure_tracking.py +++ b/tests/services/test_exposure_tracking.py @@ -49,6 +49,13 @@ def _seed_semantic(conn, sid, project="p"): class TestBootstrapExposureWrite: + @pytest.fixture(autouse=True) + def _legacy_inject_mode(self, monkeypatch): + """These tests describe the legacy dump's exposure rows; deferred + (the default since just-in-time serving) exposes nothing at + session start.""" + monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "legacy") + def test_bootstrap_writes_exposure_rows_for_injected_memories( self, conn, fixed_clock, ): From f263a43dd47bb56779429c5ec4bbb1c16bb3acdc Mon Sep 17 00:00:00 2001 From: emp3thy Date: Tue, 6 Oct 2026 00:02:23 +0100 Subject: [PATCH 14/16] fix(review): atomic per-memory claim, agentcore early return, spec-exact useful-rate denominator - SeenStore.claim: O_CREAT|O_EXCL sentinel per (session, kind, id) so parallel hook processes cannot render the same memory twice - PreToolUse returns before connect/build_backend on non-sqlite backends - useful_rate_by_channel: rated = useful + ignored + misled; overlooked shown in its own column, outside the rate - contextual_suppressed_floor is bumped by the prompt channel only Co-Authored-By: Claude Fable 5.1 --- better_memory/hooks/contextual_inject.py | 18 ++++++- better_memory/services/context_seen.py | 43 +++++++++++++++-- better_memory/ui/queries.py | 22 +++++---- better_memory/ui/templates/diagnostics.html | 12 +++-- tests/hooks/test_contextual_inject.py | 53 +++++++++++++++++++++ tests/services/test_context_seen.py | 36 ++++++++++++++ tests/ui/test_diagnostics_ratings.py | 9 ++-- website/architecture.md | 4 +- 8 files changed, 172 insertions(+), 25 deletions(-) diff --git a/better_memory/hooks/contextual_inject.py b/better_memory/hooks/contextual_inject.py index afbd671..93a70da 100644 --- a/better_memory/hooks/contextual_inject.py +++ b/better_memory/hooks/contextual_inject.py @@ -136,6 +136,13 @@ def main() -> None: with closing(connect(cfg.memory_db)) as conn: _bump_diagnostic(conn, cfg, "contextual_suppressed_nonhuman") raise _SkipInjection() + if event == "PreToolUse" and cfg.storage_backend != "sqlite": + # The trigger channel needs trigger storage, which only the + # sqlite backend has. Return before any connection or + # backend build: on agentcore that would mean a boto3 import + # and two client builds on EVERY tool call for a channel + # that cannot fire. + raise _SkipInjection() seen.bump_turn() # A real local connection is opened in BOTH backend modes: the # exposure ledger is session-operational state in the local @@ -180,6 +187,11 @@ def main() -> None: )) items = [m for m in items if (m.kind, m.id) in unseen] items = items[: cfg.context_max_items] + # Atomic per-memory claim: parallel hook processes (parallel + # tool calls) each read their own SeenStore snapshot, so + # without this both would serve the same memory. + won = set(seen.claim([(m.kind, m.id) for m in items])) + items = [m for m in items if (m.kind, m.id) in won] if items: rendered = format_relevant(items) survivors = [(m.kind, m.id) for m in items] @@ -199,7 +211,11 @@ def main() -> None: _bump_diagnostic(conn, cfg, injected_metric) elif had_candidates: _bump_diagnostic(conn, cfg, "contextual_suppressed_dedup") - else: + elif event == "UserPromptSubmit": + # The floor counter is the PROMPT gate's figure (it + # drives retuning of the distinct-hit floor); tool calls + # that match no trigger are measured by + # trigger_fired - trigger_injected instead. _bump_diagnostic(conn, cfg, "contextual_suppressed_floor") except _SkipInjection: rendered = "" diff --git a/better_memory/services/context_seen.py b/better_memory/services/context_seen.py index 5509feb..ddc2ab9 100644 --- a/better_memory/services/context_seen.py +++ b/better_memory/services/context_seen.py @@ -27,10 +27,11 @@ from datetime import datetime from pathlib import Path -# Matches the state JSON, legacy pretool sentinels, and stray `.tmp` -# siblings left behind by a process hard-killed between mkstemp and -# os.replace. -_FILE_RE = re.compile(r"^context_seen_.+\.(json|pretool|json\..+\.tmp)$") +# Matches the state JSON, per-memory claim sentinels, legacy pretool +# sentinels, and stray `.tmp` siblings left behind by a process hard-killed +# between mkstemp and os.replace. +_FILE_RE = re.compile(r"^context_seen_.+\.(json|claim|pretool|json\..+\.tmp)$") +_SAFE_KEY_RE = re.compile(r"[^A-Za-z0-9_.-]") _SAFE_SESSION_RE = re.compile(r"[^A-Za-z0-9_.-]") @@ -42,9 +43,40 @@ class SeenStore: def __init__(self, state_dir: Path, session_id: str) -> None: self._dir = state_dir safe = _SAFE_SESSION_RE.sub("_", session_id or "unknown") + self._safe = safe self._path = state_dir / f"context_seen_{safe}.json" self._data = self._load() + def claim(self, ids: list[tuple[str, str]]) -> list[tuple[str, str]]: + """Atomically claim each (kind, id) for this session; return the ones + THIS caller won, in input order. + + Hook processes run in parallel (Claude issues parallel tool calls), + and :meth:`filter_unseen` reads a snapshot taken at process start, so + two processes can both see a memory as unseen. The claim is an + ``O_CREAT|O_EXCL`` sentinel per (session, kind, id): + ``context_seen_.-.claim``. Exactly one process + creates it. Never raises: an unwritable state dir claims nothing, + so a failure degrades to "do not serve" rather than "serve twice". + """ + won: list[tuple[str, str]] = [] + try: + self._dir.mkdir(parents=True, exist_ok=True) + except BaseException: # noqa: BLE001 - cannot claim -> serve nothing + return won + for kind, id_ in ids: + name = _SAFE_KEY_RE.sub("_", f"{kind}-{id_}") + sentinel = self._dir / f"context_seen_{self._safe}.{name}.claim" + try: + fd = os.open(str(sentinel), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + os.close(fd) + won.append((kind, id_)) + except FileExistsError: + continue + except BaseException: # noqa: BLE001 - best-effort; behave as "lost" + continue + return won + def _load(self) -> dict: try: raw = json.loads(self._path.read_text(encoding="utf-8")) @@ -125,7 +157,8 @@ def mark_seen(self, ids: list[tuple[str, str]]) -> None: def prune_stale(state_dir: Path, *, now: datetime, max_age_days: int = 7) -> None: - """Delete context_seen state / pretool sentinels older than max_age_days. + """Delete context_seen state, claim sentinels and legacy pretool + sentinels older than max_age_days. Never raises. """ diff --git a/better_memory/ui/queries.py b/better_memory/ui/queries.py index 71438d7..2cda77b 100644 --- a/better_memory/ui/queries.py +++ b/better_memory/ui/queries.py @@ -883,8 +883,10 @@ def useful_rate_by_channel( ) -> dict: """Useful rate per exposure source over rated exposures. - ``useful = cited + shaped``; ``rate = useful / rated`` (0.0 when rated is - 0). ``last_n_sessions=None`` is all time; otherwise only the N most + ``useful = cited + shaped``; ``rated = useful + ignored + misled`` (the + spec's denominator -- ``overlooked`` ratings are reported in their own + column but excluded from the rate); ``rate = useful / rated`` (0.0 when + rated is 0). ``last_n_sessions=None`` is all time; otherwise only the N most recently rated sessions (by ``MAX(rated_at)`` per session) count. ``useful_per_session`` (useful ratings / rated sessions) is the guard against reaching a ratio by starving the channels (design spec @@ -892,7 +894,7 @@ def useful_rate_by_channel( Returns ``{"rows": [...], "total": {...}, "rated_sessions": int, "useful_per_session": float}``; each row has ``source``, ``rated``, - ``useful``, ``ignored``, ``misled``, ``rate``. + ``useful``, ``ignored``, ``misled``, ``overlooked``, ``rate``. """ session_filter = "" params: list[object] = [] @@ -907,10 +909,10 @@ def useful_rate_by_channel( rows = conn.execute( f""" SELECT source, - COUNT(*) AS rated, SUM(classification IN ('cited', 'shaped')) AS useful, SUM(classification = 'ignored') AS ignored, - SUM(classification = 'misled') AS misled + SUM(classification = 'misled') AS misled, + SUM(classification = 'overlooked') AS overlooked FROM session_memory_exposure WHERE rated_at IS NOT NULL {session_filter} GROUP BY source @@ -926,23 +928,25 @@ def useful_rate_by_channel( params, ).fetchone()[0] - def _row(source: str, rated: int, useful: int, ignored: int, misled: int) -> dict: + def _row(source: str, useful: int, ignored: int, misled: int, overlooked: int) -> dict: + rated = useful + ignored + misled return { "source": source, "rated": rated, "useful": useful, - "ignored": ignored, "misled": misled, + "ignored": ignored, "misled": misled, "overlooked": overlooked, "rate": (useful / rated) if rated else 0.0, } out_rows = [ - _row(r["source"], r["rated"], r["useful"] or 0, r["ignored"] or 0, r["misled"] or 0) + _row(r["source"], r["useful"] or 0, r["ignored"] or 0, r["misled"] or 0, + r["overlooked"] or 0) for r in rows ] total = _row( "all", - sum(r["rated"] for r in out_rows), sum(r["useful"] for r in out_rows), sum(r["ignored"] for r in out_rows), sum(r["misled"] for r in out_rows), + sum(r["overlooked"] for r in out_rows), ) return { "rows": out_rows, diff --git a/better_memory/ui/templates/diagnostics.html b/better_memory/ui/templates/diagnostics.html index 3a54cd4..f1508af 100644 --- a/better_memory/ui/templates/diagnostics.html +++ b/better_memory/ui/templates/diagnostics.html @@ -42,22 +42,24 @@

Recent ratings

Useful rate by channel

-

useful = cited + shaped; rate = useful / rated. Target: 33% overall - without the useful-per-session figure falling.

+

useful = cited + shaped; rated = useful + ignored + misled; rate = useful / rated. + Overlooked ratings are shown but outside the rate. Target: 33% overall without the + useful-per-session figure falling.

{% for label, stats in (("All time", useful_all), ("Last " ~ recent_window ~ " rated sessions", useful_recent)) %}

{{ label }}

- + {% for r in stats.rows %} - + + {% endfor %} - + diff --git a/tests/hooks/test_contextual_inject.py b/tests/hooks/test_contextual_inject.py index 6548c29..baf2d66 100644 --- a/tests/hooks/test_contextual_inject.py +++ b/tests/hooks/test_contextual_inject.py @@ -525,3 +525,56 @@ def test_pretool_mode_userprompt_only_disables_triggers(bm_home, monkeypatch, ca ) assert res["hookSpecificOutput"]["additionalContext"] == "" assert _exposure_sources(bm_home, "sess-t6") == {} + + +def test_preclaimed_memory_is_not_served_again(bm_home, monkeypatch, capsys): + """A parallel hook process that already claimed the memory wins; this + process injects nothing and writes no exposure.""" + from better_memory.services.context_seen import SeenStore + _seed_semantic(bm_home, "sem-heredoc", content="heredoc pitfall", triggers=["bash:<<"]) + SeenStore(bm_home / "state", "sess-claim").claim([("semantic", "sem-heredoc")]) + res = _run( + {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": _HEREDOC, + "cwd": ".", "session_id": "sess-claim"}, + monkeypatch, capsys, + ) + assert res["hookSpecificOutput"]["additionalContext"] == "" + assert _exposure_sources(bm_home, "sess-claim") == {} + + +def test_pretool_on_agentcore_returns_before_backend_work(bm_home, monkeypatch, capsys): + """The trigger channel cannot fire on agentcore (no trigger storage), so + PreToolUse must not pay for a backend build on every tool call.""" + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + + def _boom(**kwargs): + raise AssertionError("build_backend must not be called") + + monkeypatch.setattr(hook, "build_backend", _boom) + res = _run( + {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": _HEREDOC, + "cwd": ".", "session_id": "sess-ac"}, + monkeypatch, capsys, + ) + assert res["hookSpecificOutput"]["additionalContext"] == "" + conn = connect(bm_home / "memory.db") + try: + errors = conn.execute("SELECT COUNT(*) FROM hook_errors").fetchone()[0] + finally: + conn.close() + assert errors == 0 + assert _diag_value(bm_home, "contextual_fired_pretool") in (0, None) + + +def test_pretool_no_match_does_not_bump_prompt_floor_counter(bm_home, monkeypatch, capsys): + """contextual_suppressed_floor is the prompt gate's counter; tool calls + that match no trigger must not swamp it (trigger_fired - trigger_injected + is the trigger channel's own suppression figure).""" + _run( + {"hook_event_name": "PreToolUse", "tool_name": "Read", + "tool_input": {"file_path": "x.md"}, "cwd": ".", "session_id": "sess-floor"}, + monkeypatch, capsys, + ) + assert _diag_value(bm_home, "contextual_suppressed_floor") == 0 + assert _diag_value(bm_home, "trigger_fired") == 1 + assert _diag_value(bm_home, "trigger_injected") == 0 diff --git a/tests/services/test_context_seen.py b/tests/services/test_context_seen.py index 389aecb..c7bb37b 100644 --- a/tests/services/test_context_seen.py +++ b/tests/services/test_context_seen.py @@ -162,3 +162,39 @@ def test_mark_seen_merges_concurrent_writers_state(self, tmp_path): assert fresh.filter_unseen( [("reflection", "r1"), ("semantic", "m1")], reinject_turns=0, ) == [] + + +class TestClaim: + def test_claim_first_caller_wins_per_memory(self, tmp_path): + """Parallel hook processes must not both serve the same memory: the + claim is an O_CREAT|O_EXCL sentinel per (session, kind, id).""" + stores = [SeenStore(tmp_path, "sess") for _ in range(4)] + wins = [s.claim([("reflection", "r1")]) for s in stores] + assert [w == [("reflection", "r1")] for w in wins].count(True) == 1 + # Other memories and other sessions are independent. + assert stores[0].claim([("semantic", "s1")]) == [("semantic", "s1")] + assert SeenStore(tmp_path, "other").claim([("reflection", "r1")]) == [("reflection", "r1")] + + def test_claim_returns_only_unclaimed_in_order(self, tmp_path): + s = SeenStore(tmp_path, "sess") + assert s.claim([("reflection", "a")]) == [("reflection", "a")] + assert s.claim([("reflection", "a"), ("semantic", "b"), ("reflection", "c")]) == [ + ("semantic", "b"), ("reflection", "c"), + ] + + def test_claim_never_raises_on_unwritable_dir(self, tmp_path): + bad = tmp_path / "file-not-dir" + bad.write_text("x", encoding="utf-8") + # state_dir is a file: mkdir fails; claim degrades to "nothing claimed". + assert SeenStore(bad, "sess").claim([("reflection", "a")]) == [] + + def test_prune_stale_removes_claim_files(self, tmp_path): + import os + s = SeenStore(tmp_path, "sess") + s.claim([("reflection", "a")]) + files = list(tmp_path.glob("context_seen_sess*.claim")) + assert len(files) == 1 + ten_days_ago = datetime(2026, 7, 1, tzinfo=UTC).timestamp() + os.utime(files[0], (ten_days_ago, ten_days_ago)) + prune_stale(tmp_path, now=datetime(2026, 7, 11, tzinfo=UTC)) + assert not files[0].exists() diff --git a/tests/ui/test_diagnostics_ratings.py b/tests/ui/test_diagnostics_ratings.py index cafea6a..13d9b04 100644 --- a/tests/ui/test_diagnostics_ratings.py +++ b/tests/ui/test_diagnostics_ratings.py @@ -242,6 +242,8 @@ def _seed_ledger(conn): _seed_rated(conn, "s2", "m5", "retrieve", "misled", "2026-10-02T10:00:00+00:00") _seed_rated(conn, "s3", "m6", "bootstrap", "ignored", "2026-10-03T10:00:00+00:00") _seed_rated(conn, "s3", "m7", "trigger", "shaped", "2026-10-03T10:00:00+00:00") + # An overlooked rating: shown, but outside the spec's rate denominator. + _seed_rated(conn, "s3", "m9", "trigger", "overlooked", "2026-10-03T10:00:00+00:00") conn.execute( "INSERT INTO session_memory_exposure (session_id, memory_kind, memory_id, " "exposed_at, source) VALUES ('s4', 'semantic', 'm8', '2026-10-04', 'trigger')" @@ -257,16 +259,16 @@ def test_all_time_rows_total_and_guard(self, conn): rows = {r["source"]: r for r in out["rows"]} assert rows["bootstrap"] == { "source": "bootstrap", "rated": 2, "useful": 0, "ignored": 2, "misled": 0, - "rate": 0.0, + "overlooked": 0, "rate": 0.0, } assert rows["trigger"] == { "source": "trigger", "rated": 3, "useful": 2, "ignored": 1, "misled": 0, - "rate": pytest.approx(2 / 3), + "overlooked": 1, "rate": pytest.approx(2 / 3), } assert rows["retrieve"]["misled"] == 1 and rows["retrieve"]["rate"] == 0.0 assert out["total"] == { "source": "all", "rated": 7, "useful": 3, "ignored": 3, "misled": 1, - "rate": pytest.approx(3 / 7), + "overlooked": 1, "rate": pytest.approx(3 / 7), } assert out["rated_sessions"] == 3 assert out["useful_per_session"] == pytest.approx(1.0) @@ -278,6 +280,7 @@ def test_last_n_sessions_window_drops_oldest(self, conn): sources = {r["source"] for r in out["rows"]} assert "contextual" not in sources # only in s1 assert out["total"]["rated"] == 5 + assert out["total"]["overlooked"] == 1 assert out["rated_sessions"] == 2 def test_empty_ledger(self, conn): diff --git a/website/architecture.md b/website/architecture.md index 3680fd3..b868d07 100644 --- a/website/architecture.md +++ b/website/architecture.md @@ -114,9 +114,9 @@ Both channels share the rest of the path: - Survivors are filtered through a per-session `SeenStore` (`better_memory/services/context_seen.py`, JSON file at `/state/context_seen_.json`) that deduplicates memories already injected this session by either channel, then capped to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`. `BETTER_MEMORY_CONTEXT_REINJECT_TURNS=0` (default) means a memory is injected at most once per session; a positive value allows re-injection after that many turns. A "turn" is one firing of the hook: each human prompt and each PreToolUse event is a turn. - Survivors render as a `` XML block in `additionalContext`, one entry per item with its kind, id, confidence, `useful_count` and age, a `dont`-polarity item prefixed `Known pitfall -- do this instead:`, and a footer inviting `memory_credit(kind, id, class, evidence)` with a one-line evidence statement. -- Exposures are written best-effort (a write failure never blocks injection) and counted in `rating_diagnostics` per firing: `contextual_fired_userprompt`, `contextual_fired_pretool`, `contextual_injected`, `trigger_fired`, `trigger_injected`, `contextual_suppressed_floor` (no candidate cleared the gate or no trigger fired), `contextual_suppressed_dedup`, `contextual_suppressed_nonhuman`. +- Exposures are written best-effort (a write failure never blocks injection) and counted in `rating_diagnostics` per firing: `contextual_fired_userprompt`, `contextual_fired_pretool`, `contextual_injected`, `trigger_fired`, `trigger_injected`, `contextual_suppressed_floor` (prompt channel only: no candidate cleared the gate; the trigger channel's suppression figure is `trigger_fired - trigger_injected`), `contextual_suppressed_dedup`, `contextual_suppressed_nonhuman`. Survivors are also claimed atomically per (session, kind, id) through an `O_CREAT|O_EXCL` sentinel in `SeenStore.claim`, so parallel tool calls (parallel hook processes) cannot serve the same memory twice. On a non-sqlite backend the PreToolUse path returns before any connection or backend build, since the trigger channel cannot fire without trigger storage. -**Measurement.** The Diagnostics page shows a *Useful rate by channel* panel (`ui/queries.py::useful_rate_by_channel`): per exposure source, rated / useful (cited + shaped) / ignored / misled / rate, all time and over the last 15 rated sessions, plus useful ratings per rated session, the guard against reaching a ratio by serving less. The target set in the design is 33% overall over the first 15 rated sessions after deployment without that guard falling below its baseline of about 1.1. +**Measurement.** The Diagnostics page shows a *Useful rate by channel* panel (`ui/queries.py::useful_rate_by_channel`): per exposure source, useful (cited + shaped) / ignored / misled / overlooked / rate, where rated = useful + ignored + misled and overlooked ratings are shown but outside the rate, all time and over the last 15 rated sessions, plus useful ratings per rated session, the guard against reaching a ratio by serving less. The target set in the design is 33% overall over the first 15 rated sessions after deployment without that guard falling below its baseline of about 1.1. Gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE` (`userprompt` / `pretool` / `both` / `off`). The hook never raises and always exits 0 — failures are swallowed and logged to `hook_errors`, with no `additionalContext` emitted on that turn. From 730226e761f7fe63e0aa53d90a1dbcb8cc7f9d13 Mon Sep 17 00:00:00 2001 From: emp3thy Date: Tue, 6 Oct 2026 20:17:10 +0100 Subject: [PATCH 15/16] test: guard Optional returns in semantic trigger tests for pyright Co-Authored-By: Claude Fable 5.1 --- tests/services/test_semantic.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/services/test_semantic.py b/tests/services/test_semantic.py index ba7561f..39f556a 100644 --- a/tests/services/test_semantic.py +++ b/tests/services/test_semantic.py @@ -613,12 +613,14 @@ def test_set_triggers_roundtrip(self, conn): svc = SemanticMemoryService(conn) sid = svc.create(content="rule", project="p", scope="general") svc.set_triggers(id=sid, triggers=["tool:WebFetch", " bash:<< "]) - assert svc.get(id=sid).triggers == ["tool:WebFetch", "bash:<<"] + got = svc.get(id=sid) + assert got is not None and got.triggers == ["tool:WebFetch", "bash:<<"] assert svc.list_for_project(project="p", track_exposure=False)[0].triggers == [ "tool:WebFetch", "bash:<<", ] svc.set_triggers(id=sid, triggers=[]) - assert svc.get(id=sid).triggers == [] + cleared = svc.get(id=sid) + assert cleared is not None and cleared.triggers == [] assert conn.execute( "select triggers from semantic_memories where id=?", (sid,) ).fetchone()[0] is None @@ -639,6 +641,8 @@ def test_create_with_triggers(self, conn): from better_memory.services.semantic import SemanticMemoryService svc = SemanticMemoryService(conn) sid = svc.create(content="rule", project="p", triggers=["skill:superpowers:*"]) - assert svc.get(id=sid).triggers == ["skill:superpowers:*"] + with_triggers = svc.get(id=sid) + assert with_triggers is not None and with_triggers.triggers == ["skill:superpowers:*"] sid2 = svc.create(content="rule2", project="p") - assert svc.get(id=sid2).triggers == [] + without = svc.get(id=sid2) + assert without is not None and without.triggers == [] From c05cb4f003bcb8a69a24325860659bf410de6dbb Mon Sep 17 00:00:00 2001 From: emp3thy Date: Tue, 6 Oct 2026 20:20:34 +0100 Subject: [PATCH 16/16] fix(context_seen): key claim sentinels to the last-seen epoch so re-injection works BugBot (PR #134): claims were permanent, so with BETTER_MEMORY_CONTEXT_REINJECT_TURNS > 0 a memory re-admitted by filter_unseen could never win a second claim. The sentinel name now carries the turn the memory was last marked seen at in the caller's snapshot; parallel callers in one window still share a filename. Co-Authored-By: Claude Fable 5.1 --- better_memory/services/context_seen.py | 18 +++++++++++++----- tests/hooks/test_contextual_inject.py | 15 +++++++++++++++ tests/services/test_context_seen.py | 23 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/better_memory/services/context_seen.py b/better_memory/services/context_seen.py index ddc2ab9..c7604f2 100644 --- a/better_memory/services/context_seen.py +++ b/better_memory/services/context_seen.py @@ -54,19 +54,27 @@ def claim(self, ids: list[tuple[str, str]]) -> list[tuple[str, str]]: Hook processes run in parallel (Claude issues parallel tool calls), and :meth:`filter_unseen` reads a snapshot taken at process start, so two processes can both see a memory as unseen. The claim is an - ``O_CREAT|O_EXCL`` sentinel per (session, kind, id): - ``context_seen_.-.claim``. Exactly one process - creates it. Never raises: an unwritable state dir claims nothing, - so a failure degrades to "do not serve" rather than "serve twice". + ``O_CREAT|O_EXCL`` sentinel per (session, kind, id, epoch): + ``context_seen_.-..claim`` where the epoch + is the turn the memory was last marked seen at in this process's + snapshot (0 if never). Parallel callers in the same re-inject window + share a snapshot and therefore a filename, so exactly one wins; once + ``mark_seen`` records a later turn and the + ``BETTER_MEMORY_CONTEXT_REINJECT_TURNS`` window re-admits the memory, + the epoch changes and a fresh claim is possible. Never raises: an + unwritable state dir claims nothing, so a failure degrades to "do + not serve" rather than "serve twice". """ won: list[tuple[str, str]] = [] try: self._dir.mkdir(parents=True, exist_ok=True) except BaseException: # noqa: BLE001 - cannot claim -> serve nothing return won + seen = self._data.get("seen") or {} for kind, id_ in ids: name = _SAFE_KEY_RE.sub("_", f"{kind}-{id_}") - sentinel = self._dir / f"context_seen_{self._safe}.{name}.claim" + epoch = int(seen.get(_key(kind, id_)) or 0) + sentinel = self._dir / f"context_seen_{self._safe}.{name}.{epoch}.claim" try: fd = os.open(str(sentinel), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) os.close(fd) diff --git a/tests/hooks/test_contextual_inject.py b/tests/hooks/test_contextual_inject.py index baf2d66..9badba0 100644 --- a/tests/hooks/test_contextual_inject.py +++ b/tests/hooks/test_contextual_inject.py @@ -578,3 +578,18 @@ def test_pretool_no_match_does_not_bump_prompt_floor_counter(bm_home, monkeypatc assert _diag_value(bm_home, "contextual_suppressed_floor") == 0 assert _diag_value(bm_home, "trigger_fired") == 1 assert _diag_value(bm_home, "trigger_injected") == 0 + + +def test_trigger_reinjects_after_window(bm_home, monkeypatch, capsys): + """BETTER_MEMORY_CONTEXT_REINJECT_TURNS=1: the same trigger re-serves the + memory on a later firing instead of being blocked forever by the claim.""" + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_REINJECT_TURNS", "1") + _seed_semantic(bm_home, "sem-heredoc", content="heredoc pitfall", triggers=["bash:<<"]) + payload = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": _HEREDOC, + "cwd": ".", "session_id": "sess-reinject"} + first = _run(payload, monkeypatch, capsys)["hookSpecificOutput"]["additionalContext"] + assert "sem-heredoc" in first + _run({"hook_event_name": "PreToolUse", "tool_name": "Read", "tool_input": {"file_path": "x"}, + "cwd": ".", "session_id": "sess-reinject"}, monkeypatch, capsys) + third = _run(payload, monkeypatch, capsys)["hookSpecificOutput"]["additionalContext"] + assert "sem-heredoc" in third diff --git a/tests/services/test_context_seen.py b/tests/services/test_context_seen.py index c7bb37b..68d7d62 100644 --- a/tests/services/test_context_seen.py +++ b/tests/services/test_context_seen.py @@ -198,3 +198,26 @@ def test_prune_stale_removes_claim_files(self, tmp_path): os.utime(files[0], (ten_days_ago, ten_days_ago)) prune_stale(tmp_path, now=datetime(2026, 7, 11, tzinfo=UTC)) assert not files[0].exists() + + +def test_claim_reopens_after_reinject_window(tmp_path): + """A claim is keyed to the turn the memory was last seen at in the + caller's snapshot. Parallel callers sharing a snapshot get one winner; + a caller that loads a post-mark snapshot is excluded by filter_unseen + until the reinject window elapses, after which a fresh claim is possible.""" + s = _store(tmp_path) + s.bump_turn() # turn 1 + peer = _store(tmp_path) # same pre-mark snapshot + assert s.claim([("reflection", "r1")]) == [("reflection", "r1")] + assert peer.claim([("reflection", "r1")]) == [] # same window: lost + s.mark_seen([("reflection", "r1")]) + later = _store(tmp_path) + assert later.filter_unseen([("reflection", "r1")], reinject_turns=3) == [] + for _ in range(3): + _store(tmp_path).bump_turn() # turns 2..4 + s5 = _store(tmp_path) + s5.bump_turn() # turn 5 + peer5 = _store(tmp_path) # same snapshot as s5 + assert s5.filter_unseen([("reflection", "r1")], reinject_turns=3) == [("reflection", "r1")] + assert s5.claim([("reflection", "r1")]) == [("reflection", "r1")] + assert peer5.claim([("reflection", "r1")]) == [] # second winner blocked
sourceratedusefulignoredmisledrate
sourceratedusefulignoredmisledoverlookedrate
{{ r.source }}{{ r.rated }}{{ r.useful }}{{ r.ignored }}{{ r.misled }}{{ '%.0f%%' | format(r.rate * 100) }}{{ r.ignored }}{{ r.misled }}{{ r.overlooked }}{{ '%.0f%%' | format(r.rate * 100) }}
all{{ stats.total.rated }}{{ stats.total.useful }}{{ stats.total.ignored }}{{ stats.total.misled }}{{ stats.total.ignored }}{{ stats.total.misled }}{{ stats.total.overlooked }} {{ '%.0f%%' | format(stats.total.rate * 100) }}