Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f75143a
docs: deferred task-conditioned injection design (PR-D, absorbs PR-C)
emp3thy Jul 23, 2026
56e3649
docs: deferred-injection implementation plan (PR-D)
emp3thy Jul 23, 2026
60d420e
feat(config): inject_mode + context_vec_floor keys
emp3thy Jul 23, 2026
6174345
feat(db): tag exploration-slot exposures (migration 0015)
emp3thy Jul 23, 2026
f932dff
feat(embeddings): file-persisted embed cooldown for per-process hooks
emp3thy Jul 23, 2026
05a2ee0
fix(embeddings): narrow Optional down_state_file for pyright
emp3thy Jul 23, 2026
c1459b7
feat(contextual): three-leg evidence-gated relevance scorer
emp3thy Jul 23, 2026
71526ae
feat(hooks): contextual inject gains vec leg, per-session PreToolUse …
emp3thy Jul 23, 2026
87dca9b
test(hooks): pin sqlite embeddings backend — no live-Ollama dependency
emp3thy Jul 23, 2026
7133c16
feat(bootstrap): deferred mode - general semantics + index line only
emp3thy Jul 23, 2026
ab5321b
feat(docs): behavioural CLAUDE.snippet + parameter-drift sentinel
emp3thy Jul 23, 2026
fbaba33
fix(sentinel): catch prose-style param enumeration; snippet consisten…
emp3thy Jul 23, 2026
ba641c3
fix(sentinel): schema-enum exemption + param-signal precision for bac…
emp3thy Jul 23, 2026
7f130c9
fix(sentinel): word-boundary signal matching, trim signal list
emp3thy Jul 23, 2026
8df5eb6
docs(website): deferred-injection prose + config keys
emp3thy Jul 23, 2026
6b251da
fix(review): agentcore semantic fallback, sentinel docs, cooldown doc…
emp3thy Jul 24, 2026
25e3467
fix(agentcore): expose times_overlooked/times_ignored on reflection d…
emp3thy Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions better_memory/cli/install_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@ class HookSpec:
# stdout). Gated at runtime by BETTER_MEMORY_CONTEXT_INJECT_MODE; both
# events install and the mode no-ops whichever isn't selected.
HookSpec("better_memory.hooks.contextual_inject", "UserPromptSubmit", None, False, True),
HookSpec(
"better_memory.hooks.contextual_inject", "PreToolUse", "Skill|Task|Write", False, True
),
# 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.
HookSpec("better_memory.hooks.contextual_inject", "PreToolUse", None, False, True),
)

# Module paths that are no longer registered but may be present in users'
Expand Down
25 changes: 25 additions & 0 deletions better_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,13 @@ class Config:
context_min_hits: int
context_max_items: int
context_reinject_turns: int
inject_mode: Literal["deferred", "legacy"]
context_vec_floor: float
# NOTE: context_min_hits is DEPRECATED. The three-leg evidence-gated
# scorer in services/relevant.py (BM25 / vector cosine / keyword-hit
# fallback) 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.


_DEFAULT_CONTEXT_INJECT_MODE = "both"
Expand Down Expand Up @@ -260,6 +267,22 @@ def _resolve_embeddings_backend() -> Literal["ollama", "sqlite"]:
return raw # type: ignore[return-value]


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"


def _resolve_vec_floor() -> float:
raw = os.environ.get("BETTER_MEMORY_CONTEXT_VEC_FLOOR")
if raw is None:
return 0.55
try:
return min(1.0, max(0.0, float(raw)))
except ValueError:
return 0.55


def _read_settings_storage_backend(home: Path) -> str | None:
"""Read ``storage_backend`` from ``<home>/settings.json``.

Expand Down Expand Up @@ -355,4 +378,6 @@ def get_config() -> Config:
context_min_hits=_resolve_nonneg_int("BETTER_MEMORY_CONTEXT_MIN_HITS", 2),
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(),
context_vec_floor=_resolve_vec_floor(),
)
9 changes: 9 additions & 0 deletions better_memory/db/migrations/0015_via_exploration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Migration 0015: tag exposures that came from the exploration slot.
--
-- The reserved per-bucket slot (spec 2026-07-23 retrieval-quality, section 2)
-- serves under-rated memories to earn them ratings. Those serves are an
-- investment the ranker makes, not a relevance claim, so the headline
-- usefulness metric excludes them. Ratings still apply to them normally.

ALTER TABLE session_memory_exposure
ADD COLUMN via_exploration INTEGER NOT NULL DEFAULT 0;
34 changes: 34 additions & 0 deletions better_memory/embeddings/sync_embed.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@

Returns ``None`` on every failure path — callers treat a missing vector
as "no vec leg", never as an error.

The in-memory ``_down_until`` breaker is per-process, so it does nothing
for hook invocations, which are short-lived subprocesses that exit and
restart on every event. ``down_state_file`` persists the cooldown deadline
to disk (wall-clock ``time.time()``, not the monotonic ``clock``) so a
fresh hook process can see that Ollama was recently marked down and skip
straight to ``None`` instead of re-probing a dead endpoint on every event.
"""

from __future__ import annotations

import time
from collections.abc import Callable
from pathlib import Path
from typing import Any

from better_memory.async_bridge import run_async_in_worker
Expand All @@ -38,12 +46,14 @@ def __init__(
clock: Callable[[], float] = time.monotonic,
cooldown: float = _DEFAULT_COOLDOWN,
timeout: float = _WORKER_TIMEOUT,
down_state_file: Path | None = None,
) -> None:
self._factory = factory
self._clock = clock
self._cooldown = cooldown
self._timeout = timeout
self._down_until = 0.0
self._down_file = down_state_file

def embed_text(self, text: str) -> list[float] | None:
return self._run(lambda emb: emb.embed(text))
Expand All @@ -56,6 +66,8 @@ def _run(self, op: Callable[[Any], Any]):
return None
if self._clock() < self._down_until:
return None
if self._down_file is not None and self._file_down():
return None

factory = self._factory

Expand All @@ -72,4 +84,26 @@ async def _go():
return run_async_in_worker(_go, timeout=self._timeout)
except Exception:
self._down_until = self._clock() + self._cooldown
self._write_down_file()
return None

def _file_down(self) -> bool:
down = self._down_file
if down is None:
return False
try:
until = float(down.read_text(encoding="utf-8").strip())
except Exception:
return False
return time.time() < until

def _write_down_file(self) -> None:
down = self._down_file
if down is None:
return
try:
down.parent.mkdir(parents=True, exist_ok=True)
down.write_text(
str(time.time() + self._cooldown), encoding="utf-8")
except Exception:
pass
92 changes: 92 additions & 0 deletions better_memory/hooks/_claude_md_sentinel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Detect parameter-enumeration drift in the user's CLAUDE.md.

Pure functions; the session_bootstrap hook wires them in best-effort.
Only lines that mention a better-memory tool name are scanned. Two token
shapes are checked against the live schema, so prose can't false-positive
on unrelated words:

- `word=` / `word:` — explicit parameter-usage syntax.
- `` `word` `` — a backtick-quoted snake_case identifier, the shape real
CLAUDE.md prose uses when enumerating params conversationally (e.g.
"optional `component` ... and `scope_path`"). To avoid flagging plain
identifier mentions (e.g. "called from `session_bootstrap` module") this
branch only fires on lines that also carry a parameter-signal word (see
`_SIGNAL_RE`).

The accepted-token set per tool is schema-derived, not just property
names: enum *values* (e.g. `memory_retrieve`'s `polarity` enum
`do`/`dont`/`neutral`) are valid tokens too — those are documented return
shapes, not phantom parameters, and real CLAUDE.md prose legitimately
backtick-quotes them right next to the tool name.
"""

from __future__ import annotations

import re

_PARAM_RE = re.compile(r"\b([a-z_][a-z0-9_]{2,})\s*[=:]")
_BACKTICK_RE = re.compile(r"`([a-z_]{4,})`")
_IGNORE = {"http", "https", "note", "example", "warning", "default", "docs"}

# Words whose presence on a line signal "this backtick token is being
# documented as a parameter", as opposed to a plain identifier or module
# mention. Matched case-insensitively on word boundaries — a naive
# substring check would match "set" inside "reset"/"preset"/"subset".
# "field" and "defaults" were dropped: both false-positived on unrelated
# backticked identifiers and no incident fixture depends on either.
_SIGNAL_RE = re.compile(
r"\b(optional|parameter|param|pass|passing|tune|filter|argument|arg|set)\b",
re.IGNORECASE,
)


def build_schemas() -> dict[str, set[str]]:
from better_memory.mcp.tools import tool_definitions
out: dict[str, set[str]] = {}
for tool in tool_definitions():
rendered = tool.name.replace(".", "_")
properties = (tool.inputSchema or {}).get("properties", {}) or {}
tokens: set[str] = set()
for prop_name, spec in properties.items():
tokens.add(prop_name)
if isinstance(spec, dict):
enum_values = spec.get("enum")
if isinstance(enum_values, list):
tokens.update(v for v in enum_values if isinstance(v, str))
out[rendered] = tokens
return out


def _has_signal_word(line: str) -> bool:
return _SIGNAL_RE.search(line) is not None


def check_claude_md(text: str, schemas: dict[str, set[str]]) -> list[str]:
warnings: list[str] = []
try:
for line in (text or "").splitlines():
hit_tools = [name for name in schemas if name in line]
if not hit_tools:
continue
valid: set[str] = set()
for t in hit_tools:
valid |= schemas[t]
candidates = list(_PARAM_RE.findall(line))
if _has_signal_word(line):
candidates += _BACKTICK_RE.findall(line)
seen: set[str] = set()
for token in candidates:
if token in seen:
continue
seen.add(token)
if token in _IGNORE or token in valid:
continue
if token in schemas: # a tool name itself, not a param
continue
warnings.append(
f"CLAUDE.md documents parameter '{token}' near "
f"{'/'.join(hit_tools)} but the live tool schema has no "
"such parameter - fix or drop the enumeration.")
except Exception:
return []
return warnings[:1] # at most one line of noise per session
48 changes: 37 additions & 11 deletions better_memory/hooks/contextual_inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
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 and must clear a min-hits floor
(cfg.context_min_hits) and fit within a max-items cap (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,
Candidates are scored via retrieve_relevant's three-leg evidence gate (BM25 /
vector cosine / keyword-hit fallback — 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).

NOTE: whether PreToolUse fires for the built-in Skill/Task tools is
environment-dependent (see the plan's Task 0 probe); UserPromptSubmit is the
reliable trigger.
PreToolUse is latched to one real firing per session (SeenStore.pretool_fired
/ mark_pretool_fired): 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 state file
before any DB/embedder work. UserPromptSubmit is unaffected by the latch.
"""
from __future__ import annotations

Expand All @@ -26,6 +28,8 @@

from better_memory.config import get_config, project_name
from better_memory.db.connection import connect
from better_memory.embeddings.ollama import OllamaEmbedder
from better_memory.embeddings.sync_embed import SyncEmbedder
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
Expand All @@ -34,6 +38,14 @@
_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.
"""


def _bump_diagnostic(conn, cfg, metric: str) -> None:
"""Best-effort observability counter. Sqlite mode only; never raises."""
if cfg.storage_backend != "sqlite" or conn is None:
Expand Down Expand Up @@ -95,6 +107,10 @@ 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":
if seen.pretool_fired():
raise _SkipInjection() # module-local sentinel; caught below
seen.mark_pretool_fired()
seen.bump_turn()
conn_ctx = (
closing(connect(cfg.memory_db))
Expand All @@ -114,9 +130,17 @@ def main() -> None:
session_id=session_id or None,
project=project,
)
sync_embedder = None
if cfg.embeddings_backend == "ollama":
sync_embedder = SyncEmbedder(
lambda: OllamaEmbedder(timeout=5.0, max_retries=1),
down_state_file=cfg.home / "state" / "embed_down_until",
)
items = retrieve_relevant(
backend, query=query, project=project,
min_hits=cfg.context_min_hits,
conn=conn,
sync_embedder=sync_embedder,
vec_floor=cfg.context_vec_floor,
max_items=cfg.context_max_items,
)
had_candidates = bool(items)
Expand Down Expand Up @@ -145,6 +169,8 @@ def main() -> None:
_bump_diagnostic(conn, cfg, "contextual_suppressed_dedup")
else:
_bump_diagnostic(conn, cfg, "contextual_suppressed_floor")
except _SkipInjection:
rendered = ""
except BaseException as exc: # noqa: BLE001
try:
record_hook_error(hook_name="contextual_inject", exc=exc)
Expand Down
17 changes: 17 additions & 0 deletions better_memory/hooks/session_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ def main() -> None:
source=source, session_id=session_id, cwd=Path(cwd_str),
)
rendered = result.additional_context

try:
claude_md_path = Path.home() / ".claude" / "CLAUDE.md"
if claude_md_path.is_file():
from better_memory.hooks._claude_md_sentinel import (
build_schemas,
check_claude_md,
)

claude_md_text = claude_md_path.read_text(
encoding="utf-8", errors="ignore",
)
sentinel_warnings = check_claude_md(claude_md_text, build_schemas())
if sentinel_warnings:
rendered = rendered + "\n\n" + sentinel_warnings[0]
except BaseException: # noqa: BLE001 — sentinel is best-effort
pass
except BaseException as exc: # noqa: BLE001
try:
record_hook_error(hook_name="session_bootstrap", exc=exc)
Expand Down
17 changes: 15 additions & 2 deletions better_memory/services/context_seen.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
degrades to "nothing seen".

File format: ``context_seen_<session_id>.json`` ->
``{"turn": int, "seen": {"<kind>:<id>": last_injected_turn}}``.
``{"turn": int, "seen": {"<kind>:<id>": last_injected_turn},
"pretool_fired": bool}``. ``pretool_fired`` latches PreToolUse to one real
firing per session (see :meth:`SeenStore.pretool_fired`).
"""
from __future__ import annotations

Expand Down Expand Up @@ -34,7 +36,11 @@ def _load(self) -> dict:
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
if isinstance(raw, dict) and isinstance(raw.get("seen"), dict):
return {"turn": int(raw.get("turn") or 0), "seen": raw["seen"]}
return {
"turn": int(raw.get("turn") or 0),
"seen": raw["seen"],
"pretool_fired": bool(raw.get("pretool_fired")),
}
except BaseException: # noqa: BLE001 - corrupt/missing -> empty
pass
return {"turn": 0, "seen": {}}
Expand Down Expand Up @@ -70,6 +76,13 @@ def mark_seen(self, ids: list[tuple[str, str]]) -> None:
self._data["seen"][_key(kind, id_)] = turn
self._save()

def pretool_fired(self) -> bool:
return bool(self._data.get("pretool_fired"))

def mark_pretool_fired(self) -> None:
self._data["pretool_fired"] = True
self._save()


def prune_stale(state_dir: Path, *, now: datetime, max_age_days: int = 7) -> None:
"""Delete context_seen files older than max_age_days. Never raises."""
Expand Down
Loading