Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The script:
3. Checks for Ollama; offers to install via `brew` / `apt` / `winget` if missing.
4. Pulls `nomic-embed-text`.
5. Creates `~/.better-memory/{spool,knowledge-base/...}`.
6. Auto-installs the MCP server registration into `~/.claude.json` and the three hooks (`session_bootstrap`, `observer`, `session_close`) into `~/.claude/settings.json` (idempotent; backups go to `~/.better-memory/install-backups/`).
6. Auto-installs the MCP server registration into `~/.claude.json` and the hooks (`session_bootstrap`, `observer`, `session_close`, and `contextual_inject` on `UserPromptSubmit` + `PreToolUse`) into `~/.claude/settings.json` (idempotent; backups go to `~/.better-memory/install-backups/`).

If you'd rather inspect or hand-edit the config, see [Manual setup](#manual-setup) below.

Expand Down
23 changes: 21 additions & 2 deletions better_memory/cli/install_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
@dataclass(frozen=True)
class HookSpec:
module: str # e.g. "better_memory.hooks.session_start"
event: str # "SessionStart" | "PostToolUse" | "Stop"
matcher: str | None # None for SessionStart/Stop; "Write|Edit|Bash" for observer
event: str # a Claude Code hook event name
matcher: str | None # None for unscoped events; tool-name alternation otherwise
is_async: bool # True for PostToolUse + Stop
needs_stdout: bool # True for SessionStart bootstrap — Claude Code reads
# the hook's stdout for additionalContext, so the
Expand All @@ -56,6 +56,14 @@ class HookSpec:
HookSpec("better_memory.hooks.session_bootstrap", "SessionStart", None, False, True),
HookSpec("better_memory.hooks.observer", "PostToolUse", "Write|Edit|Bash", True, False),
HookSpec("better_memory.hooks.session_close", "Stop", None, True, False),
# Contextual injection: surface curated memories relevant to the current
# prompt / tool-input. Sync + needs_stdout (reads additionalContext from
# stdout). Gated at runtime by BETTER_MEMORY_CONTEXT_INJECT_MODE; both
# events install and the mode no-ops whichever isn't selected.
HookSpec("better_memory.hooks.contextual_inject", "UserPromptSubmit", None, False, True),
HookSpec(
"better_memory.hooks.contextual_inject", "PreToolUse", "Skill|Task|Write", False, True
),
)

# Module paths that are no longer registered but may be present in users'
Expand Down Expand Up @@ -183,6 +191,17 @@ def merge_settings_json(
"hooks": [_hook_entry(spec, venv_py, venv_pyw)],
})

for spec in (s for s in _OUR_HOOKS if s.event == "UserPromptSubmit"):
hooks.setdefault("UserPromptSubmit", []).append({
"hooks": [_hook_entry(spec, venv_py, venv_pyw)],
})

for spec in (s for s in _OUR_HOOKS if s.event == "PreToolUse"):
group = {"hooks": [_hook_entry(spec, venv_py, venv_pyw)]}
if spec.matcher is not None:
group["matcher"] = spec.matcher
hooks.setdefault("PreToolUse", []).append(group)

config["hooks"] = hooks
return config

Expand Down
18 changes: 18 additions & 0 deletions better_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,23 @@ class Config:
agentcore_region: str
agentcore_semantic_memory_id: str | None
agentcore_episodic_memory_id: str | None
context_inject_mode: Literal["userprompt", "pretool", "both", "off"]


_DEFAULT_CONTEXT_INJECT_MODE = "both"
_VALID_CONTEXT_INJECT_MODES = ("userprompt", "pretool", "both", "off")


def _resolve_context_inject_mode() -> Literal["userprompt", "pretool", "both", "off"]:
raw = os.environ.get(
"BETTER_MEMORY_CONTEXT_INJECT_MODE", _DEFAULT_CONTEXT_INJECT_MODE
)
if raw not in _VALID_CONTEXT_INJECT_MODES:
raise ValueError(
f"BETTER_MEMORY_CONTEXT_INJECT_MODE must be one of "
f"{_VALID_CONTEXT_INJECT_MODES}, got {raw!r}"
)
return raw # type: ignore[return-value]


def _resolve_embeddings_backend() -> Literal["ollama", "sqlite"]:
Expand Down Expand Up @@ -279,4 +296,5 @@ def get_config() -> Config:
agentcore_region=agentcore_region,
agentcore_semantic_memory_id=agentcore_semantic_memory_id,
agentcore_episodic_memory_id=agentcore_episodic_memory_id,
context_inject_mode=_resolve_context_inject_mode(),
)
103 changes: 103 additions & 0 deletions better_memory/hooks/contextual_inject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""UserPromptSubmit / PreToolUse hook: inject curated memories relevant to the
current prompt or tool-input. Gated by BETTER_MEMORY_CONTEXT_INJECT_MODE
(userprompt | pretool | both | off). Never raises; always exits 0.

NOTE: whether PreToolUse fires for the built-in Skill/Task tools is
environment-dependent (see the plan's Task 0 probe); UserPromptSubmit is the
reliable trigger.
"""
from __future__ import annotations

import json
import os
import sys
from contextlib import closing
from pathlib import Path

from better_memory.config import get_config, project_name
from better_memory.db.connection import connect
from better_memory.hooks._error_log import record_hook_error
from better_memory.services.relevant import format_relevant, retrieve_relevant
from better_memory.storage import build_backend

_MAX_STDIN_BYTES = 1_000_000


def _enabled(event: str, mode: str) -> bool:
if mode == "off":
return False
if event == "UserPromptSubmit":
return mode in ("userprompt", "both")
if event == "PreToolUse":
return mode in ("pretool", "both")
return False


def _query_from(payload: dict, event: str) -> str:
if event == "UserPromptSubmit":
return str(payload.get("prompt") or "")
if event == "PreToolUse":
tool = payload.get("tool_name") or ""
return f"{tool} {json.dumps(payload.get('tool_input') or {})}"
return ""


def main() -> None:
raw = ""
try:
raw = sys.stdin.read(_MAX_STDIN_BYTES + 1)
except BaseException: # noqa: BLE001 — hooks never fail
pass
payload: dict = {}
if raw.strip() and len(raw) <= _MAX_STDIN_BYTES:
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
payload = parsed
except BaseException: # noqa: BLE001
pass

event = str(payload.get("hook_event_name") or "UserPromptSubmit")
rendered = ""
try:
cfg = get_config()
if _enabled(event, cfg.context_inject_mode):
query = _query_from(payload, event)
cwd = str(payload.get("cwd") or os.getcwd())
project = project_name(Path(cwd))
with closing(connect(cfg.memory_db)) as conn:
backend = build_backend(
config=cfg,
memory_conn=conn,
embedder=None,
session_id=None,
project=project,
)
items = retrieve_relevant(
backend, query=query, project=project, limit=5
)
rendered = format_relevant(items)
except BaseException as exc: # noqa: BLE001
try:
record_hook_error(hook_name="contextual_inject", exc=exc)
except BaseException: # noqa: BLE001
pass
rendered = ""

try:
print(
json.dumps({
"hookSpecificOutput": {
"hookEventName": event,
"additionalContext": rendered,
}
}),
flush=True,
)
except BaseException: # noqa: BLE001
pass
sys.exit(0)


if __name__ == "__main__":
main()
42 changes: 42 additions & 0 deletions better_memory/services/keywords.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Keyword extraction + whole-word matching for contextual memory relevance.

Pure, dependency-free. Used by retrieve_relevant to filter the curated memory
set (semantic + reflections) against the current prompt / tool-input.
"""
from __future__ import annotations

import re

# Small, deliberately conservative stopword set. Tunable.
_STOPWORDS = frozenset({
"the", "and", "for", "are", "was", "were", "you", "your", "our", "with",
"this", "that", "from", "into", "have", "has", "had", "but", "not", "can",
"will", "would", "should", "could", "lets", "let", "get", "got", "out",
"use", "using", "what", "when", "how", "why", "who", "all", "any", "its",
"his", "her", "they", "them", "then", "than", "now", "via", "per",
})

_TOKEN_RE = re.compile(r"[a-z0-9]+")


def extract_keywords(text: str) -> set[str]:
"""Lowercase, tokenise on non-alphanumerics, drop stopwords + <3-char tokens."""
if not text:
return set()
return {
tok
for tok in _TOKEN_RE.findall(text.lower())
if len(tok) >= 3 and tok not in _STOPWORDS
}


def count_keyword_hits(text: str, keywords: set[str]) -> int:
"""Number of distinct keywords that appear as a WHOLE WORD in text."""
if not text or not keywords:
return 0
lowered = text.lower()
hits = 0
for kw in keywords:
if re.search(rf"\b{re.escape(kw)}\b", lowered):
hits += 1
return hits
102 changes: 102 additions & 0 deletions better_memory/services/relevant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Relevance filter over the curated memory set (semantic + reflections).

Fetches the small, already-ranked sets through the StorageBackend abstraction
(works on sqlite AND agentcore), whole-word keyword-filters them against a query,
and returns the top matches. Pure-Python; no embeddings, no new schema.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from better_memory.services.keywords import count_keyword_hits, extract_keywords


@dataclass
class RelevantMemory:
kind: str # "reflection" | "semantic"
id: str
summary: str # short display text
confidence: float | None
hits: int # distinct keyword hits (higher = more relevant)


def _reflection_text(r: dict[str, Any]) -> str:
parts = [str(r.get("title") or ""), str(r.get("use_cases") or "")]
hints = r.get("hints") or []
if isinstance(hints, list):
parts.extend(str(h) for h in hints)
return " ".join(parts)


def retrieve_relevant(
backend: Any,
*,
query: str,
project: str,
limit: int = 5,
include_neutral: bool = False,
) -> list[RelevantMemory]:
"""Return up to ``limit`` curated memories whose text whole-word-matches a
keyword from ``query``, ordered by (# hits desc, managed rank asc).

Never raises: any backend error yields an empty list (the hook must not break
a turn). "Managed rank" is the order the backend already returned items in
(confidence / useful-count): reflections flattened do -> dont -> [neutral],
then semantic.
"""
keywords = extract_keywords(query)
if not keywords:
return []

candidates: list[tuple[int, RelevantMemory]] = [] # (managed_rank, mem)
rank = 0

try:
buckets = backend.retrieve(project=project, track_exposure=False)
except Exception: # noqa: BLE001 — degrade to no reflections
buckets = {}
order = ["do", "dont"] + (["neutral"] if include_neutral else [])
for bucket in order:
for r in buckets.get(bucket, []) or []:
hits = count_keyword_hits(_reflection_text(r), keywords)
if hits:
candidates.append((rank, RelevantMemory(
kind="reflection",
id=str(r.get("id")),
summary=str(r.get("title") or _reflection_text(r))[:160],
confidence=r.get("confidence"),
hits=hits,
)))
rank += 1

try:
semantic = backend.semantic_list(project=project, track_exposure=False)
except Exception: # noqa: BLE001 — degrade to no semantic
semantic = []
for s in semantic or []:
content = getattr(s, "content", "") or ""
hits = count_keyword_hits(content, keywords)
if hits:
candidates.append((rank, RelevantMemory(
kind="semantic",
id=str(getattr(s, "id", "")),
summary=content[:160],
confidence=None,
hits=hits,
)))
rank += 1

candidates.sort(key=lambda t: (-t[1].hits, t[0]))
return [m for _, m in candidates[:limit]]


def format_relevant(items: list[RelevantMemory], *, max_items: int = 5) -> str:
"""Render the additionalContext block (<= max_items). Empty if no items."""
if not items:
return ""
lines = ["RELEVANT MEMORY — apply unless it conflicts with the user's request:"]
for m in items[:max_items]:
tag = m.kind + (f" · conf {m.confidence:.2f}" if m.confidence is not None else "")
lines.append(f"• [{tag}] {m.summary}")
return "\n".join(lines)
31 changes: 30 additions & 1 deletion docs/hooks-setup.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
# better-memory hook registration

better-memory ships three hooks that wire into Claude Code's hook framework:
better-memory ships these hooks that wire into Claude Code's hook framework:

| Hook | Purpose | Module |
|---|---|---|
| `SessionStart` | Open/reuse a background episode and inject project + general semantic memories and reflections as `additionalContext` | `better_memory.hooks.session_bootstrap` |
| `PostToolUse` | Capture every tool invocation as a spool event | `better_memory.hooks.observer` |
| `Stop` | Mark session end for consolidation boundary detection | `better_memory.hooks.session_close` |
| `UserPromptSubmit` + `PreToolUse` | Inject curated memories (semantic + reflections) relevant to the current prompt / tool-input as `additionalContext` | `better_memory.hooks.contextual_inject` |

The `contextual_inject` hook is gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE`
(`userprompt` \| `pretool` \| `both` (default) \| `off`). It runs in-process
against `memory.db`, whole-word-matches the curated memory set against the
prompt / tool-input, and injects the top matches; it never blocks a turn.
Note: whether `PreToolUse` fires for the built-in `Skill`/`Task` tools is
environment-dependent — `UserPromptSubmit` is the reliable trigger.

## Registering the hooks

Expand Down Expand Up @@ -49,6 +57,27 @@ project-scoped):
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "uv run python -m better_memory.hooks.contextual_inject"
}
]
}
],
"PreToolUse": [
{
"matcher": "Skill|Task|Write",
"hooks": [
{
"type": "command",
"command": "uv run python -m better_memory.hooks.contextual_inject"
}
]
}
]
}
}
Expand Down
Loading