diff --git a/README.md b/README.md index 0e789a1..994d1d3 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Then add to `~/.claude.json` (user-scope MCP — create the file if it doesn't e And add hooks to `~/.claude/settings.json`: -A single SessionStart hook ships: `session_bootstrap` opens (or reuses) a background episode for the session and injects the project's curated context — both project-scoped and general-scope semantic memories plus all distilled reflections (`do` / `dont` / `neutral` buckets) — as `additionalContext` so Claude has prior memory available without needing to call any retrieval tool first. The hook does its work in-process against `memory.db`; if it fails for any reason, a fallback directive is injected instructing Claude to call `mcp__better-memory__memory_session_bootstrap` manually. +Four hooks ship. `session_bootstrap` (SessionStart) opens (or reuses) a background episode for the session and injects the project's curated context — project-scoped and general-scope semantic memories plus distilled reflections (`do` / `dont` / `neutral` buckets) — as `additionalContext` so Claude has prior memory available without needing to call any retrieval tool first. Only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5) render in full; the rest collapse into a one-line index plus a retrieve affordance (`BETTER_MEMORY_BOOTSTRAP_TOP_N=0` restores the old full-dump behavior). The hook does its work in-process against `memory.db`; if it fails for any reason, a fallback directive is injected instructing Claude to call `mcp__better-memory__memory_session_bootstrap` manually. `contextual_inject` (UserPromptSubmit + PreToolUse) additionally surfaces memories relevant to the current prompt/tool-input mid-session — see [Configuration](website/configuration.md) and [Architecture](website/architecture.md#injection-strategies) for how it scores, floors, and dedups candidates. ```json { @@ -124,11 +124,34 @@ A single SessionStart hook ships: `session_bootstrap` opens (or reuses) a backgr } ] } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "/absolute/path/to/.venv/bin/python -m better_memory.hooks.contextual_inject" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Skill|Task|Write", + "hooks": [ + { + "type": "command", + "command": "/absolute/path/to/.venv/bin/python -m better_memory.hooks.contextual_inject" + } + ] + } ] } } ``` +`contextual_inject` is gated at runtime by `BETTER_MEMORY_CONTEXT_INJECT_MODE` (default `both`) — set it to `userprompt`, `pretool`, or `off` to narrow or disable which event actually injects. + On Windows, point hooks at `.venv\Scripts\pythonw.exe` (no console flash) instead of `python.exe`. Restart Claude Code. MCP servers don't hot-reload. @@ -146,6 +169,13 @@ One env var roots the runtime filesystem layout: | `BETTER_MEMORY_EMBEDDINGS_BACKEND` | `ollama` | `ollama` (default) — local Ollama at `OLLAMA_HOST`; `sqlite` — pure-SQL trigram-FTS5 fusion, no model downloads and no in-memory state. | | `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_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt`, `pretool`, `both` (default), or `off`. | +| `BETTER_MEMORY_BOOTSTRAP_TOP_N` | `5` | Number of project-scoped semantic memories/reflections the SessionStart bootstrap renders in full; the rest collapse into a one-line index. `0` = legacy full dump. | +| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | Minimum distinct keyword hits a memory needs to clear the contextual-injection floor. | +| `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. | + +See [Configuration](website/configuration.md) for the full table and injection-tuning detail. ### Project-name override @@ -236,7 +266,7 @@ The server registers 22 tools, grouped below. Full schemas are in [`website/mcp- | Tool | Purpose | |---|---| -| `memory.session_bootstrap(source?, session_id?, cwd?)` | Open or reuse a session episode and inject project + general semantic memories and reflections as `additionalContext` markdown. Reflections capped at 20 per polarity bucket, ranked by usefulness then confidence. Mirrors the SessionStart hook; callable manually for recovery, testing, or post-`/clear` re-injection. | +| `memory.session_bootstrap(source?, session_id?, cwd?)` | Open or reuse a session episode and inject project + general semantic memories and reflections as `additionalContext` markdown. Reflections retrieved up to 20 per polarity bucket, ranked by usefulness then confidence; only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items render in full, the rest collapse into a one-line index. Mirrors the SessionStart hook; callable manually for recovery, testing, or post-`/clear` re-injection. | | `memory.run_retention(retention_days?, prune?, prune_age_days?, dry_run?)` | Apply spec §9 retention rules; archive or hard-delete. | | `memory.start_ui()` | Spawn or reuse the management UI; returns `{url, reused}`. | diff --git a/better_memory/config.py b/better_memory/config.py index f66aa9f..db562fe 100644 --- a/better_memory/config.py +++ b/better_memory/config.py @@ -12,7 +12,10 @@ Default home is ``~/.better-memory``. External-service knobs (``OLLAMA_HOST``, ``EMBED_MODEL``, ``AUDIT_LOG_RETRIEVED``, ``BETTER_MEMORY_EMBEDDINGS_BACKEND``) are separate env vars because they're -orthogonal to path layout. +orthogonal to path layout. Injection-tuning knobs +(``BETTER_MEMORY_BOOTSTRAP_TOP_N``, ``BETTER_MEMORY_CONTEXT_MIN_HITS``, +``BETTER_MEMORY_CONTEXT_MAX_ITEMS``, ``BETTER_MEMORY_CONTEXT_REINJECT_TURNS``) +control content injection strategies. """ from __future__ import annotations @@ -193,6 +196,19 @@ def _resolve_bool(env_var: str, default: bool) -> bool: return raw.strip().lower() in {"1", "true", "yes", "on"} +def _resolve_nonneg_int(env_var: str, default: int) -> int: + raw = os.environ.get(env_var) + if raw is None: + return default + try: + value = int(raw) + except ValueError as exc: + raise ValueError(f"{env_var} must be a non-negative integer, got {raw!r}") from exc + if value < 0: + raise ValueError(f"{env_var} must be a non-negative integer, got {raw!r}") + return value + + @dataclass(frozen=True) class Config: """Resolved better-memory configuration.""" @@ -213,6 +229,10 @@ class Config: agentcore_semantic_memory_id: str | None agentcore_episodic_memory_id: str | None context_inject_mode: Literal["userprompt", "pretool", "both", "off"] + bootstrap_top_n: int + context_min_hits: int + context_max_items: int + context_reinject_turns: int _DEFAULT_CONTEXT_INJECT_MODE = "both" @@ -297,4 +317,8 @@ def get_config() -> Config: agentcore_semantic_memory_id=agentcore_semantic_memory_id, agentcore_episodic_memory_id=agentcore_episodic_memory_id, context_inject_mode=_resolve_context_inject_mode(), + bootstrap_top_n=_resolve_nonneg_int("BETTER_MEMORY_BOOTSTRAP_TOP_N", 5), + 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), ) diff --git a/better_memory/db/migrations/0012_contextual_exposure.sql b/better_memory/db/migrations/0012_contextual_exposure.sql new file mode 100644 index 0000000..2e54b27 --- /dev/null +++ b/better_memory/db/migrations/0012_contextual_exposure.sql @@ -0,0 +1,41 @@ +-- Migration 0012: contextual exposure source + contextual diagnostics. +-- +-- The contextual_inject hook (UserPromptSubmit / PreToolUse) now records +-- exposures so contextually-injected memories are rateable. SQLite cannot +-- ALTER a CHECK constraint, so session_memory_exposure is recreated to +-- widen source IN ('bootstrap','retrieve') to include 'contextual'. +-- 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')), + rated_at TEXT, + classification TEXT CHECK(classification IN + ('cited', 'shaped', 'ignored', 'misled', 'overlooked')), + 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) +SELECT + session_id, memory_kind, memory_id, exposed_at, source, rated_at, classification +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); + +-- Contextual-injection observability counters (R1=A in the spec). + +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_fired_userprompt', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_fired_pretool', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_injected', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_suppressed_floor', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_suppressed_dedup', 0); diff --git a/better_memory/hooks/contextual_inject.py b/better_memory/hooks/contextual_inject.py index 8ef6eca..f158be2 100644 --- a/better_memory/hooks/contextual_inject.py +++ b/better_memory/hooks/contextual_inject.py @@ -2,6 +2,15 @@ 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, +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. @@ -11,18 +20,35 @@ import json import os import sys -from contextlib import closing +from contextlib import closing, nullcontext +from datetime import UTC, datetime 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.context_seen import SeenStore, prune_stale from better_memory.services.relevant import format_relevant, retrieve_relevant from better_memory.storage import build_backend _MAX_STDIN_BYTES = 1_000_000 +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: + return + try: + conn.execute( + "UPDATE rating_diagnostics SET value = value + 1, updated_at = ? " + "WHERE metric = ?", + (datetime.now(UTC).isoformat(), metric), + ) + conn.commit() + except BaseException: # noqa: BLE001 + pass + + def _enabled(event: str, mode: str) -> bool: if mode == "off": return False @@ -63,20 +89,62 @@ def main() -> None: cfg = get_config() if _enabled(event, cfg.context_inject_mode): query = _query_from(payload, event) + session_id = str(payload.get("session_id") or "") cwd = str(payload.get("cwd") or os.getcwd()) project = project_name(Path(cwd)) - with closing(connect(cfg.memory_db)) as conn: + state_dir = cfg.home / "state" + prune_stale(state_dir, now=datetime.now(UTC)) + seen = SeenStore(state_dir, session_id) + seen.bump_turn() + conn_ctx = ( + closing(connect(cfg.memory_db)) + if cfg.storage_backend == "sqlite" + else nullcontext(None) + ) + with conn_ctx as conn: + _bump_diagnostic( + conn, cfg, + "contextual_fired_userprompt" if event == "UserPromptSubmit" + else "contextual_fired_pretool", + ) backend = build_backend( config=cfg, memory_conn=conn, embedder=None, - session_id=None, + session_id=session_id or None, project=project, ) items = retrieve_relevant( - backend, query=query, project=project, limit=5 + backend, query=query, project=project, + min_hits=cfg.context_min_hits, + max_items=cfg.context_max_items, ) - rendered = format_relevant(items) + 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] + if items: + rendered = format_relevant(items) + survivors = [(m.kind, m.id) for m in items] + try: + backend.record_exposures( + session_id=session_id, + items=survivors, + source="contextual", + ) + except BaseException as exc: # noqa: BLE001 - never block injection + try: + record_hook_error(hook_name="contextual_inject_exposure", exc=exc) + except BaseException: # noqa: BLE001 + pass + seen.mark_seen(survivors) + _bump_diagnostic(conn, cfg, "contextual_injected") + elif had_candidates: + _bump_diagnostic(conn, cfg, "contextual_suppressed_dedup") + else: + _bump_diagnostic(conn, cfg, "contextual_suppressed_floor") except BaseException as exc: # noqa: BLE001 try: record_hook_error(hook_name="contextual_inject", exc=exc) diff --git a/better_memory/hooks/session_close.py b/better_memory/hooks/session_close.py index 6b4047a..2e99640 100644 --- a/better_memory/hooks/session_close.py +++ b/better_memory/hooks/session_close.py @@ -139,6 +139,7 @@ def _emit_rating_directive_if_unrated(session_id: str) -> bool: """ SELECT e.memory_kind, e.memory_id, MIN(e.exposed_at) AS exposed_at, + MIN(e.source) AS source, COALESCE(r.title, s.content) AS display FROM session_memory_exposure e LEFT JOIN reflections r ON e.memory_kind='reflection' @@ -160,17 +161,25 @@ def _emit_rating_directive_if_unrated(session_id: str) -> bool: CAP_BYTES = 8 * 1024 refl_lines = [] sem_lines = [] + source_counts: dict[str, int] = {} for r in rows: display = (r["display"] or "")[:TRUNC] + source = r["source"] or "bootstrap" + source_counts[source] = source_counts.get(source, 0) + 1 + line = f"- {r['memory_id']} [{source}]: {display}" if r["memory_kind"] == "reflection": - refl_lines.append(f"- {r['memory_id']}: {display}") + refl_lines.append(line) else: - sem_lines.append(f"- {r['memory_id']}: {display}") + sem_lines.append(line) + counts_line = "sources: " + ", ".join( + f"{k} {v}" for k, v in sorted(source_counts.items()) + ) directive = ( "RATE_MEMORIES — before this session ends, classify the " "memories that were exposed during this session and that you " - "did NOT already credit via memory.credit.\n\n" + "did NOT already credit via memory.credit.\n" + f"({counts_line})\n\n" f"Reflections ({len(refl_lines)}):\n" + ("\n".join(refl_lines) if refl_lines else " (none)") + f"\n\nSemantic memories ({len(sem_lines)}):\n" diff --git a/better_memory/services/context_seen.py b/better_memory/services/context_seen.py new file mode 100644 index 0000000..11ae420 --- /dev/null +++ b/better_memory/services/context_seen.py @@ -0,0 +1,82 @@ +"""Per-session seen-store for contextual memory injection dedup. + +Backend-independent (works in agentcore mode where there is no exposure +table) and cheap: one small JSON file per session under +``/state``. Never raises: corrupt or unwritable state +degrades to "nothing seen". + +File format: ``context_seen_.json`` -> +``{"turn": int, "seen": {":": last_injected_turn}}``. +""" +from __future__ import annotations + +import json +import re +from datetime import datetime +from pathlib import Path + +_FILE_RE = re.compile(r"^context_seen_.+\.json$") +_SAFE_SESSION_RE = re.compile(r"[^A-Za-z0-9_.-]") + + +def _key(kind: str, id_: str) -> str: + return f"{kind}:{id_}" + + +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._path = state_dir / f"context_seen_{safe}.json" + self._data = self._load() + + 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"]} + except BaseException: # noqa: BLE001 - corrupt/missing -> empty + pass + return {"turn": 0, "seen": {}} + + def _save(self) -> None: + try: + self._dir.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(self._data), encoding="utf-8") + except BaseException: # noqa: BLE001 - best-effort + pass + + def bump_turn(self) -> int: + self._data["turn"] = int(self._data.get("turn") or 0) + 1 + self._save() + return self._data["turn"] + + def filter_unseen( + self, ids: list[tuple[str, str]], *, reinject_turns: int, + ) -> list[tuple[str, str]]: + turn = int(self._data.get("turn") or 0) + out: list[tuple[str, str]] = [] + for kind, id_ in ids: + last = self._data["seen"].get(_key(kind, id_)) + if last is None: + out.append((kind, id_)) + elif reinject_turns > 0 and (turn - int(last)) > reinject_turns: + out.append((kind, id_)) + return out + + def mark_seen(self, ids: list[tuple[str, str]]) -> None: + turn = int(self._data.get("turn") or 0) + for kind, id_ in ids: + self._data["seen"][_key(kind, id_)] = turn + 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.""" + try: + cutoff = now.timestamp() - max_age_days * 86400 + for f in state_dir.iterdir(): + if _FILE_RE.match(f.name) and f.stat().st_mtime < cutoff: + f.unlink(missing_ok=True) + except BaseException: # noqa: BLE001 + pass diff --git a/better_memory/services/reflection.py b/better_memory/services/reflection.py index 34442e6..8b0f98a 100644 --- a/better_memory/services/reflection.py +++ b/better_memory/services/reflection.py @@ -1225,7 +1225,8 @@ def retrieve_reflections( rows = self._conn.execute( f""" SELECT id, title, phase, polarity, use_cases, hints, - confidence, tech, evidence_count, useful_count + confidence, tech, evidence_count, useful_count, + times_misled, updated_at FROM reflections WHERE {where} ORDER BY (useful_count + ? * times_overlooked) DESC, @@ -1252,6 +1253,8 @@ def retrieve_reflections( "tech": r["tech"], "evidence_count": r["evidence_count"], "useful_count": r["useful_count"], + "times_misled": r["times_misled"], + "updated_at": r["updated_at"], }) _diag.step( fn, "bucketed", diff --git a/better_memory/services/relevant.py b/better_memory/services/relevant.py index f5e58fb..c392152 100644 --- a/better_memory/services/relevant.py +++ b/better_memory/services/relevant.py @@ -1,12 +1,19 @@ """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. +(works on sqlite AND agentcore) and scores them against a query using +hits x activation: distinct whole-word keyword hits (title hits count double) +multiplied by an activation factor built from useful_count and confidence, +halved when a memory has misled more often than it has helped. Items below +a min-hits floor are dropped; the remainder is ranked by score and capped +to max_items. Pure-Python; no embeddings, no new schema. """ from __future__ import annotations +import math +from collections.abc import Callable from dataclasses import dataclass +from datetime import UTC, datetime from typing import Any from better_memory.services.keywords import count_keyword_hits, extract_keywords @@ -14,19 +21,36 @@ @dataclass class RelevantMemory: - kind: str # "reflection" | "semantic" + kind: str # "reflection" | "semantic" id: str - summary: str # short display text + text: str # full display text (renderer truncates) + polarity: str | None # "do" | "dont" | None for semantic confidence: float | None - hits: int # distinct keyword hits (higher = more relevant) + useful_count: int + age_days: int | None + hits: int + score: float -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 _age_days(iso_ts: str | None, now: datetime) -> int | None: + if not iso_ts: + return None + try: + ts = datetime.fromisoformat(iso_ts) + except (ValueError, TypeError): + return None + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + return max(0, (now - ts).days) + + +def _activation(*, useful_count: int, times_misled: int, confidence: float | None) -> float: + act = (1.0 + 0.2 * math.log1p(max(0, useful_count))) + if confidence is not None: + act *= max(0.1, float(confidence)) + if times_misled > useful_count: + act *= 0.5 + return act def retrieve_relevant( @@ -34,69 +58,113 @@ def retrieve_relevant( *, query: str, project: str, - limit: int = 5, + min_hits: int = 2, + max_items: int = 3, include_neutral: bool = False, + now: Callable[[], datetime] | None = None, ) -> 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. - """ + """Score curated memories against the query; return top max_items whose + distinct-keyword hits >= min_hits, ordered by score desc. Never raises.""" keywords = extract_keywords(query) if not keywords: return [] + _now = (now or (lambda: datetime.now(UTC)))() - candidates: list[tuple[int, RelevantMemory]] = [] # (managed_rank, mem) - rank = 0 + out: list[RelevantMemory] = [] try: buckets = backend.retrieve(project=project, track_exposure=False) - except Exception: # noqa: BLE001 — degrade to no reflections + 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 + title = str(r.get("title") or "") + body = " ".join( + [str(r.get("use_cases") or "")] + + [str(h) for h in (r.get("hints") or [])] + ) + title_hits = count_keyword_hits(title, keywords) + total_hits = count_keyword_hits(f"{title} {body}", keywords) + if total_hits < min_hits: + continue + act = _activation( + useful_count=int(r.get("useful_count") or 0), + times_misled=int(r.get("times_misled") or 0), + confidence=r.get("confidence"), + ) + score = (total_hits + title_hits) * act # title hits count double + out.append(RelevantMemory( + kind="reflection", id=str(r.get("id")), + 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=total_hits, score=score, + )) try: semantic = backend.semantic_list(project=project, track_exposure=False) - except Exception: # noqa: BLE001 — degrade to no semantic + 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 hits < min_hits: + continue + act = _activation( + useful_count=int(getattr(s, "useful_count", 0) or 0), + times_misled=int(getattr(s, "times_misled", 0) or 0), + confidence=None, + ) + out.append(RelevantMemory( + kind="semantic", id=str(getattr(s, "id", "")), + 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=hits, score=hits * act, + )) + + out.sort(key=lambda m: (-m.score, m.id)) + return out[:max_items] + + +_TEXT_MAX_CHARS = 400 + +_BLOCK_HEADER = ( + '\n' + "Prior knowledge from past sessions in this project " + "(factual records; verify if stale):" +) +_BLOCK_FOOTER = ( + "If any entry above materially helps or misleads this task, credit it now: " + "memory_credit(kind, id, 'cited'|'shaped'|'misled').\n" + "" +) + + +def _meta_tag(m: RelevantMemory) -> str: + parts = [f"{m.kind} {m.id}"] + if m.confidence is not None: + parts.append(f"conf {m.confidence:.1f}") + if m.useful_count: + parts.append(f"used {m.useful_count}x") + if m.age_days is not None: + parts.append(f"{m.age_days}d old") + return "[" + " | ".join(parts) + "]" + + +def format_relevant(items: list[RelevantMemory]) -> str: + """Render the additionalContext block. Empty string if no items.""" if not items: return "" - lines = ["RELEVANT MEMORY — apply unless it conflicts with the user's request:"] - for m in items[:max_items]: - tag = m.kind + (f" · conf {m.confidence:.2f}" if m.confidence is not None else "") - lines.append(f"• [{tag}] {m.summary}") + lines = [_BLOCK_HEADER] + for i, m in enumerate(items, start=1): + text = m.text if len(m.text) <= _TEXT_MAX_CHARS else m.text[: _TEXT_MAX_CHARS - 3] + "..." + if m.polarity == "dont": + text = f"Known pitfall -- do this instead: {text}" + lines.append(f"{i}. {_meta_tag(m)}") + lines.append(f" {text}") + lines.append(_BLOCK_FOOTER) return "\n".join(lines) diff --git a/better_memory/services/session_bootstrap.py b/better_memory/services/session_bootstrap.py index cbdbab3..ab08210 100644 --- a/better_memory/services/session_bootstrap.py +++ b/better_memory/services/session_bootstrap.py @@ -12,7 +12,7 @@ - Markdown rendering for ``additionalContext`` injection. Connection ownership: caller owns the sqlite3 connection. Episode opens -commit through ``EpisodeService``'s existing SAVEPOINT envelope. ``_record_exposure`` +commit through ``EpisodeService``'s existing SAVEPOINT envelope. ``record_exposures`` issues its own commit for the exposure write — callers must not wrap ``bootstrap()`` in an outer transaction they intend to roll back. """ @@ -53,25 +53,44 @@ def _render_header(*, project: str, source: str, action: str, episode_id: str) - ) -def _render_semantic(items) -> tuple[str, list[str]]: +def _age_suffix(iso_ts: str | None, now: datetime) -> str: + if not iso_ts: + return "" + try: + ts = datetime.fromisoformat(iso_ts) + except ValueError: + return "" + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + return f" ({max(0, (now - ts).days)}d old)" + + +def _render_semantic_full(items, now: datetime) -> tuple[str, list[str]]: if not items: return "", [] - lines = [f"### Semantic memories ({len(items)} entries)"] + lines = [f"### Semantic memories ({len(items)} shown in full)"] ids: list[str] = [] for m in items: - lines.append(f"- [{m.id[:8]}] {_truncate(m.content)}") + lines.append(f"- [{m.id}]{_age_suffix(m.updated_at, now)} {_truncate(m.content)}") ids.append(m.id) return "\n".join(lines), ids -def _render_reflection_bucket(name: str, items) -> tuple[str, list[str]]: - if not items: +def _render_reflections_full(pairs, now: datetime) -> tuple[str, list[str]]: + """pairs: list of (polarity, reflection-dict) already capped to top-N.""" + if not pairs: return "", [] blocks: list[str] = [] ids: list[str] = [] - for item in items: + for polarity, item in pairs: + label = { + "do": "do", + "dont": "dont (pitfall - do the corrective action)", + "neutral": "neutral", + }[polarity] lines = [ - f"**{item['title']}**", + f"**{item['title']}** [{label}]" + f"{_age_suffix(item.get('updated_at'), now)}", f"_{item['use_cases']}_", ] for hint in item.get("hints", []): @@ -79,7 +98,22 @@ def _render_reflection_bucket(name: str, items) -> tuple[str, list[str]]: lines.append(f"_id: {item['id']}_") blocks.append("\n".join(lines)) ids.append(item["id"]) - return f"### Reflections — {name}\n" + "\n\n".join(blocks), ids + return "### Reflections (shown in full)\n" + "\n\n".join(blocks), ids + + +def _render_index(sem_index, refl_index) -> tuple[str, int]: + n = len(sem_index) + len(refl_index) + if n == 0: + return "", 0 + lines = ["### Index (not expanded - retrieve on demand)"] + for polarity, item in refl_index: + conf = item.get("confidence") + conf_s = f", conf {conf:.1f}" if isinstance(conf, (int, float)) else "" + lines.append(f"- {item['title']} ({polarity}{conf_s})") + for m in sem_index: + first_line = (m.content or "").splitlines()[0][:100] + lines.append(f"- {first_line} (semantic)") + return "\n".join(lines), n @dataclass(frozen=True) @@ -101,43 +135,56 @@ def __init__( conn, *, clock: Callable[[], datetime] | None = None, + top_n: int | None = None, ) -> None: self._conn = conn self._clock: Callable[[], datetime] = clock or (lambda: datetime.now(UTC)) self._episodes = EpisodeService(conn) + self._top_n = top_n - def _record_exposure( + def record_exposures( self, *, session_id: str, - reflection_ids: list[str], - semantic_ids: list[str], + items: list[tuple[str, str]], + source: str, ) -> None: - """Write one row per injected memory into session_memory_exposure. + """Write one session_memory_exposure row per (kind, id) item. - Called only after rendering succeeded, so we don't credit memories - the LLM never actually saw. Best-effort: skips entirely if - session_id is empty (e.g., manual invocation without env). + Best-effort: skips entirely when session_id is empty. Own commit + (see module docstring on connection ownership). """ - if not session_id: + if not session_id or not items: return now = self._clock().isoformat() - rows = ( - [(session_id, "reflection", rid, now, "bootstrap") - for rid in reflection_ids] + - [(session_id, "semantic", sid, now, "bootstrap") - for sid in semantic_ids] - ) - if not rows: - return self._conn.executemany( "INSERT OR IGNORE INTO session_memory_exposure " "(session_id, memory_kind, memory_id, exposed_at, source) " "VALUES (?, ?, ?, ?, ?)", - rows, + [(session_id, kind, mid, now, source) for kind, mid in items], ) self._conn.commit() + def _record_exposure( + self, + *, + session_id: str, + reflection_ids: list[str], + semantic_ids: list[str], + ) -> None: + """Write one row per injected memory into session_memory_exposure. + + Called only after rendering succeeded, so we don't credit memories + the LLM never actually saw. Best-effort: skips entirely if + session_id is empty (e.g., manual invocation without env). + """ + self.record_exposures( + session_id=session_id, + items=[("reflection", rid) for rid in reflection_ids] + + [("semantic", sid) for sid in semantic_ids], + source="bootstrap", + ) + def bootstrap( self, *, @@ -195,6 +242,33 @@ def bootstrap( "neutral": len(buckets["neutral"]), } + if self._top_n is not None: + top_n = self._top_n + else: + from better_memory.config import get_config + top_n = get_config().bootstrap_top_n + + now = self._clock() + + if top_n == 0: + sem_full, sem_index = list(semantic), [] + else: + general = [m for m in semantic if m.scope == "general"] + project_scoped = [m for m in semantic if m.scope != "general"] + sem_full = general + project_scoped[:top_n] + sem_index = project_scoped[top_n:] + + flat_reflections = ( + [("do", r) for r in buckets["do"]] + + [("dont", r) for r in buckets["dont"]] + + [("neutral", r) for r in buckets["neutral"]] + ) + if top_n == 0: + refl_full, refl_index = flat_reflections, [] + else: + refl_full = flat_reflections[:top_n] + refl_index = flat_reflections[top_n:] + sections: list[str] = [ _render_header( project=project, @@ -203,26 +277,31 @@ def bootstrap( episode_id=episode_id, ), ] - sem_section, semantic_ids = _render_semantic(semantic) + sem_section, semantic_ids = _render_semantic_full(sem_full, now) if sem_section: sections.append(sem_section) - do_section, do_ids = _render_reflection_bucket("do (prior wins)", buckets["do"]) - if do_section: - sections.append(do_section) - dont_section, dont_ids = _render_reflection_bucket("dont (approaches to avoid)", buckets["dont"]) - if dont_section: - sections.append(dont_section) - neutral_section, neutral_ids = _render_reflection_bucket("neutral (context)", buckets["neutral"]) - if neutral_section: - sections.append(neutral_section) + refl_section, reflection_ids = _render_reflections_full(refl_full, now) + if refl_section: + sections.append(refl_section) + index_section, index_count = _render_index(sem_index, refl_index) + if index_section: + sections.append(index_section) sections.append("---") - sections.append(_FOOTER) + footer = _FOOTER + if index_count: + footer = ( + f"{index_count} more memories are indexed above - call " + "mcp__better-memory__memory_retrieve or " + "mcp__better-memory__memory_retrieve_observations when a task " + "touches them.\n" + _FOOTER + ) + sections.append(footer) rendered = "\n\n".join(sections) self._record_exposure( session_id=session_id, - reflection_ids=do_ids + dont_ids + neutral_ids, + reflection_ids=reflection_ids, semantic_ids=semantic_ids, ) diff --git a/better_memory/storage/agentcore.py b/better_memory/storage/agentcore.py index 0a0483b..44abb45 100644 --- a/better_memory/storage/agentcore.py +++ b/better_memory/storage/agentcore.py @@ -286,7 +286,7 @@ def _num(key: str) -> float: return { # Public shape — must match ReflectionSynthesisService.retrieve_reflections # return: {id, title, phase, use_cases, hints (list), confidence (float), - # tech, evidence_count, useful_count} + # tech, evidence_count, useful_count, times_misled, updated_at} "id": rec["memoryRecordId"], "title": body.get("title", "") if isinstance(body, dict) else "", "phase": phase_value, @@ -296,6 +296,10 @@ def _num(key: str) -> float: "tech": tech_value, "evidence_count": int(_num("useful_count")) + int(_num("missed_count")), "useful_count": int(_num("useful_count")), + "times_misled": int(_num("times_misled")), + "updated_at": ( + updated_at.isoformat() if isinstance(updated_at, datetime) else None + ), # Internal ranking helpers — leading underscore so callers ignore "_overlooked_count": int(_num("overlooked_count")), "_updated_at_ts": updated_at_ts, @@ -878,6 +882,15 @@ def _fetch_semantic() -> dict[str, Any]: # already signals this at the Protocol level). } + def record_exposures( + self, + *, + session_id: str, + items: list[tuple[str, str]], + source: str, + ) -> None: + """No-op: agentcore mode has no exposure log (see list_session_exposures).""" + def list_session_exposures(self, *, session_id: str) -> dict[str, Any]: """Per spec Rating model section: no exposure log in agentcore mode. Returns the standard envelope shape with an empty exposures list.""" diff --git a/better_memory/storage/protocol.py b/better_memory/storage/protocol.py index 1fe21b3..890cea2 100644 --- a/better_memory/storage/protocol.py +++ b/better_memory/storage/protocol.py @@ -95,8 +95,9 @@ def retrieve( Each bucket is a list of reflection dicts: ``{id, title, phase, use_cases, hints (list[str]), confidence (float), tech, - evidence_count, useful_count}``. Sync — no embedder call (reflections - are pre-extracted in both backends; sqlite mode ranks via SQL + evidence_count, useful_count, times_misled, updated_at}``. Sync — + no embedder call (reflections are pre-extracted in both backends; + sqlite mode ranks via SQL ORDER BY ``useful_count + 3 * times_overlooked DESC``, agentcore mode applies the same formula client-side over metadata counters). @@ -257,6 +258,18 @@ def apply_session_ratings( """Atomically apply per-exposure ratings. Returns ApplySessionRatingsResult.""" ... + def record_exposures( + self, + *, + session_id: str, + items: list[tuple[str, str]], + source: str, + ) -> None: + """Record (kind, id) memory exposures for later rating. Sqlite writes + session_memory_exposure rows; agentcore is a documented no-op (it has + no exposure log - rating flows through credit_one).""" + ... + def credit_one( self, *, diff --git a/better_memory/storage/sqlite.py b/better_memory/storage/sqlite.py index 18abe4f..aa1ad87 100644 --- a/better_memory/storage/sqlite.py +++ b/better_memory/storage/sqlite.py @@ -288,6 +288,17 @@ def list_session_exposures(self, *, session_id: str) -> dict[str, Any]: session_id=session_id, ) + def record_exposures( + self, + *, + session_id: str, + items: list[tuple[str, str]], + source: str, + ) -> None: + self._session_bootstrap.record_exposures( + session_id=session_id, items=items, source=source, + ) + def apply_session_ratings( self, *, diff --git a/docs/superpowers/plans/2026-07-11-attention-first-injection.md b/docs/superpowers/plans/2026-07-11-attention-first-injection.md new file mode 100644 index 0000000..0987b05 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-attention-first-injection.md @@ -0,0 +1,1555 @@ +# Attention-First Injection Redesign 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:** Make injected memories get the LLM's attention (slim bootstrap, relevance-floored + deduped + XML-formatted contextual injection) and make every injected memory rateable (exposure tracking + rating affordances). + +**Architecture:** Backend-agnostic scoring stays pure-Python over `StorageBackend.retrieve()` / `.semantic_list()`. Contextual injections gain a score floor, per-session seen-file dedup, `` XML rendering with full ids/ages, and exposure rows via a new `record_exposures` protocol method (sqlite insert / agentcore no-op). Bootstrap renders top-N in full + one-line index for the rest. Stop-hook rating directive gains per-source labels. + +**Tech Stack:** Python 3.12, sqlite3, pytest (`asyncio_mode=auto`), ruff preset E,F,I,B,UP,SIM. + +**Spec:** `docs/superpowers/specs/2026-07-11-attention-first-injection-design.md` (approved; R1=A, R2=A). + +## Guardrails (from project memory — apply throughout) + +- **[[98056ebc]] Docs sync (conf 0.95):** every env var / behaviour change lands in README + website/* in the SAME PR — Task 12 enumerates the files; module docstrings that enumerate config knobs update in the same task as the code. +- **[[2dcd790a]] No tautological tests (conf 0.9):** every new test must fail before the change. For formatting tests, anchor on specific markup (e.g. ` file 2>&1` redirect order or `--junit-xml` on Windows. + +--- + +### Task 1: Config — four new env vars + +**Files:** +- Modify: `better_memory/config.py` (Config dataclass ~line 196; `get_config()` ~line 254; module docstring if it enumerates knobs) +- Test: `tests/test_config.py` + +**Interfaces:** +- Produces: `Config.bootstrap_top_n: int`, `Config.context_min_hits: int`, `Config.context_max_items: int`, `Config.context_reinject_turns: int` — consumed by Tasks 4, 6, 8, 9. + +**Confidence: 95%** + +- [ ] **Step 1: Write failing tests** (append to `tests/test_config.py`, follow the file's existing monkeypatch style): + +```python +def test_injection_knobs_defaults(monkeypatch): + for var in ( + "BETTER_MEMORY_BOOTSTRAP_TOP_N", + "BETTER_MEMORY_CONTEXT_MIN_HITS", + "BETTER_MEMORY_CONTEXT_MAX_ITEMS", + "BETTER_MEMORY_CONTEXT_REINJECT_TURNS", + ): + monkeypatch.delenv(var, raising=False) + cfg = get_config() + assert cfg.bootstrap_top_n == 5 + assert cfg.context_min_hits == 2 + assert cfg.context_max_items == 3 + assert cfg.context_reinject_turns == 0 + + +def test_injection_knobs_env_override(monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_BOOTSTRAP_TOP_N", "0") + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_MIN_HITS", "1") + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_MAX_ITEMS", "5") + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_REINJECT_TURNS", "20") + cfg = get_config() + assert cfg.bootstrap_top_n == 0 + assert cfg.context_min_hits == 1 + assert cfg.context_max_items == 5 + assert cfg.context_reinject_turns == 20 + + +def test_injection_knobs_invalid_raises(monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_MIN_HITS", "banana") + with pytest.raises(ValueError): + get_config() + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_MIN_HITS", "-1") + with pytest.raises(ValueError): + get_config() +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/test_config.py -q` +Expected: FAIL — `AttributeError: 'Config' object has no attribute 'bootstrap_top_n'` (or TypeError on construction). + +- [ ] **Step 3: Implement** in `better_memory/config.py`: + +Add resolver next to `_resolve_bool`: + +```python +def _resolve_nonneg_int(env_var: str, default: int) -> int: + raw = os.environ.get(env_var) + if raw is None: + return default + try: + value = int(raw) + except ValueError as exc: + raise ValueError(f"{env_var} must be a non-negative integer, got {raw!r}") from exc + if value < 0: + raise ValueError(f"{env_var} must be a non-negative integer, got {raw!r}") + return value +``` + +Add fields to the frozen `Config` dataclass (defaults on the field are unnecessary — `get_config` always passes them — but keep dataclass field order: append after `context_inject_mode`): + +```python + bootstrap_top_n: int + context_min_hits: int + context_max_items: int + context_reinject_turns: int +``` + +In `get_config()` return, append: + +```python + bootstrap_top_n=_resolve_nonneg_int("BETTER_MEMORY_BOOTSTRAP_TOP_N", 5), + 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), +``` + +If `config.py`'s module docstring enumerates env vars, add the four new ones there in this task. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_config.py -q` — Expected: PASS. +Run: `uv run ruff check better_memory/config.py tests/test_config.py` — Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/config.py tests/test_config.py +git commit -m "feat(config): injection tuning knobs (bootstrap_top_n, context_min_hits/max_items/reinject_turns)" +``` + +--- + +### Task 2: Migration 0011 — 'contextual' exposure source + diagnostics metrics + +**Files:** +- Create: `better_memory/db/migrations/0011_contextual_exposure.sql` +- Test: `tests/db/test_migration_0011.py` + +**Interfaces:** +- Produces: `session_memory_exposure.source` accepts `'contextual'`; `rating_diagnostics` rows `contextual_fired_userprompt`, `contextual_fired_pretool`, `contextual_injected`, `contextual_suppressed_floor`, `contextual_suppressed_dedup`. Consumed by Tasks 7, 8. + +**Confidence: 93%** (table-recreation risk mitigated by round-trip test below) + +- [ ] **Step 1: Write failing test** `tests/db/test_migration_0011.py`. Mirror the structure of `tests/db/test_migration_0009.py` / existing migration tests: use `apply_migrations(conn, migrations_dir=)` with a tempdir holding 0001..0010 to reach the pre-state, then a tempdir holding only 0011 (this two-tempdir pattern is required because `apply_migrations` skips versions already in `schema_migrations`): + +```python +"""Migration 0011: widen exposure source CHECK to include 'contextual'; +seed contextual diagnostics counters. Data-preservation round-trip per +project reflection 96936ffc.""" +import shutil +from pathlib import Path + +import pytest + +from better_memory.db.schema import apply_migrations + +MIGRATIONS = Path("better_memory/db/migrations") + + +@pytest.fixture +def pre_0011_conn(tmp_path, fresh_conn): + """Connection with 0001..0010 applied (fresh_conn fixture from tests/db + conftest; if the conftest instead provides a raw connection factory, + open an in-memory conn with row_factory=sqlite3.Row the same way the + other migration tests do).""" + pre_dir = tmp_path / "pre" + pre_dir.mkdir() + for f in sorted(MIGRATIONS.glob("*.sql")): + if f.name < "0011": + shutil.copy(f, pre_dir / f.name) + apply_migrations(fresh_conn, migrations_dir=pre_dir) + return fresh_conn + + +def _apply_0011(conn, tmp_path): + post_dir = tmp_path / "post" + post_dir.mkdir() + shutil.copy(MIGRATIONS / "0011_contextual_exposure.sql", post_dir) + apply_migrations(conn, migrations_dir=post_dir) + + +def test_0011_accepts_contextual_source(pre_0011_conn, tmp_path): + conn = pre_0011_conn + _apply_0011(conn, tmp_path) + conn.execute( + "INSERT INTO session_memory_exposure " + "(session_id, memory_kind, memory_id, exposed_at, source) " + "VALUES ('s1', 'reflection', 'r1', '2026-07-11T00:00:00+00:00', 'contextual')" + ) + row = conn.execute( + "SELECT source FROM session_memory_exposure WHERE session_id='s1'" + ).fetchone() + assert row["source"] == "contextual" + + +def test_0011_round_trip_preserves_rows(pre_0011_conn, tmp_path): + conn = pre_0011_conn + conn.execute( + "INSERT INTO session_memory_exposure " + "(session_id, memory_kind, memory_id, exposed_at, source, rated_at, classification) " + "VALUES ('s0', 'semantic', 'm0', '2026-07-10T09:00:00+00:00', " + "'bootstrap', '2026-07-10T10:00:00+00:00', 'cited')" + ) + conn.commit() + _apply_0011(conn, tmp_path) + row = conn.execute( + "SELECT * FROM session_memory_exposure WHERE session_id='s0'" + ).fetchone() + assert row["memory_kind"] == "semantic" + assert row["memory_id"] == "m0" + assert row["exposed_at"] == "2026-07-10T09:00:00+00:00" + assert row["source"] == "bootstrap" + assert row["rated_at"] == "2026-07-10T10:00:00+00:00" + assert row["classification"] == "cited" + + +def test_0011_seeds_contextual_diagnostics(pre_0011_conn, tmp_path): + conn = pre_0011_conn + _apply_0011(conn, tmp_path) + metrics = { + r["metric"] + for r in conn.execute("SELECT metric FROM rating_diagnostics").fetchall() + } + assert { + "contextual_fired_userprompt", + "contextual_fired_pretool", + "contextual_injected", + "contextual_suppressed_floor", + "contextual_suppressed_dedup", + } <= metrics + + +def test_0011_indexes_recreated(pre_0011_conn, tmp_path): + conn = pre_0011_conn + _apply_0011(conn, tmp_path) + 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 +``` + +Adjust the fixture to the actual conftest in `tests/db/` (read `tests/db/test_migration_0009.py` first and copy its connection-setup idiom exactly). + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/db/test_migration_0011.py -q` +Expected: FAIL — 0011 file not found. + +- [ ] **Step 3: Write migration** `better_memory/db/migrations/0011_contextual_exposure.sql` (same recreation pattern as 0010): + +```sql +-- Migration 0011: contextual exposure source + contextual diagnostics. +-- +-- The contextual_inject hook (UserPromptSubmit / PreToolUse) now records +-- exposures so contextually-injected memories are rateable. SQLite cannot +-- ALTER a CHECK constraint, so session_memory_exposure is recreated to +-- widen source IN ('bootstrap','retrieve') to include 'contextual'. +-- 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')), + rated_at TEXT, + classification TEXT CHECK(classification IN + ('cited', 'shaped', 'ignored', 'misled', 'overlooked')), + 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) +SELECT + session_id, memory_kind, memory_id, exposed_at, source, rated_at, classification +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); + +-- Contextual-injection observability counters (R1=A in the spec). + +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_fired_userprompt', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_fired_pretool', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_injected', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_suppressed_floor', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_suppressed_dedup', 0); +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/db/ -q` — Expected: PASS (including older migration tests; if any test hardcodes "10 migrations" or exact schema state, update it — known anti-pattern [[76cc650c]]). + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/db/migrations/0011_contextual_exposure.sql tests/db/test_migration_0011.py +git commit -m "feat(db): migration 0011 - contextual exposure source + diagnostics counters" +``` + +--- + +### Task 3: Reflection dicts gain `times_misled` + `updated_at` (both backends) + +**Files:** +- Modify: `better_memory/services/reflection.py:1225-1255` (SELECT + bucket dict) +- Modify: `better_memory/storage/agentcore.py:286-302` (`_parse_reflection_record` return dict) +- Modify: `better_memory/storage/protocol.py:94-105` (`retrieve` docstring key list) +- Test: `tests/services/test_reflection_retrieve_fields.py` (new), `tests/storage/test_agentcore_unit.py` (extend) + +**Interfaces:** +- Produces: every reflection dict from `backend.retrieve()` additionally carries `times_misled: int` and `updated_at: str | None` (ISO-8601). Consumed by Task 4 scoring. + +**Confidence: 92%** + +- [ ] **Step 1: Write failing sqlite test** `tests/services/test_reflection_retrieve_fields.py`. Use the existing reflection seed helper if `tests/services/` has one (grep for how `tests/services/test_useful_count_ranking.py` seeds reflections and reuse that helper/idiom — per guardrail [[1ad537fd]] extend helpers rather than writing new inline INSERT): + +```python +def test_retrieve_reflections_includes_misled_and_updated_at(reflection_conn_with_one_row): + """Every bucket dict must carry times_misled and updated_at so the + contextual relevance scorer can apply the misled penalty and age.""" + svc = ReflectionSynthesisService(reflection_conn_with_one_row) + buckets = svc.retrieve_reflections(project="proj", track_exposure=False) + item = (buckets["do"] + buckets["dont"] + buckets["neutral"])[0] + assert item["times_misled"] == 0 + assert isinstance(item["updated_at"], str) and item["updated_at"] +``` + +(Fixture: whatever existing helper seeds one reflection for project "proj" — copy from `test_useful_count_ranking.py`.) + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/services/test_reflection_retrieve_fields.py -q` +Expected: FAIL — `KeyError: 'times_misled'`. + +- [ ] **Step 3: Implement sqlite side** — in `reflection.py` `retrieve_reflections`: + - SELECT list becomes: `id, title, phase, polarity, use_cases, hints, confidence, tech, evidence_count, useful_count, times_misled, updated_at` + - bucket dict gains: `"times_misled": r["times_misled"], "updated_at": r["updated_at"],` + +- [ ] **Step 4: Implement agentcore side** — in `_parse_reflection_record`'s return dict add: + +```python + "times_misled": int(_num("times_misled")), + "updated_at": ( + updated_at.isoformat() if isinstance(updated_at, datetime) else None + ), +``` + +(`updated_at` local already exists at agentcore.py:283. Metadata key `times_misled` is seeded at agentcore.py:495; records missing it yield 0 via `_num`.) + +- [ ] **Step 5: Extend agentcore unit test** — in `tests/storage/test_agentcore_unit.py`, find the existing `_parse_reflection_record` shape test and extend its assertions: + +```python + assert parsed["times_misled"] == 0 + assert parsed["updated_at"] is None or isinstance(parsed["updated_at"], str) +``` + +Also update the protocol docstring key list at `protocol.py:94-105` to read `{id, title, phase, use_cases, hints (list[str]), confidence (float), tech, evidence_count, useful_count, times_misled, updated_at}`. + +- [ ] **Step 6: Run tests** + +Run: `uv run pytest tests/services/test_reflection_retrieve_fields.py tests/storage/test_agentcore_unit.py tests/services/ -q` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add better_memory/services/reflection.py better_memory/storage/agentcore.py better_memory/storage/protocol.py tests/services/test_reflection_retrieve_fields.py tests/storage/test_agentcore_unit.py +git commit -m "feat(retrieve): reflection dicts carry times_misled + updated_at on both backends" +``` + +--- + +### Task 4: Relevance scoring v2 (`relevant.py`) + +**Files:** +- Modify: `better_memory/services/relevant.py` (replace `RelevantMemory` + `retrieve_relevant`; `format_relevant` is replaced in Task 5) +- Test: `tests/services/test_relevant.py` (extend existing file) + +**Interfaces:** +- Consumes: Task 3's `times_misled`/`updated_at` keys; `SemanticMemory` fields (`useful_count`, `times_misled`, `updated_at`, `scope`) — all existing. +- Produces: + `RelevantMemory(kind, id, text, polarity, confidence, useful_count, age_days, hits, score)` and + `retrieve_relevant(backend, *, query, project, min_hits=2, max_items=3, include_neutral=False, now=None) -> list[RelevantMemory]`. + Consumed by Tasks 5 and 8. + +**Confidence: 91%** + +- [ ] **Step 1: Write failing tests** (extend `tests/services/test_relevant.py`; reuse its existing fake-backend idiom — read the file first and copy its fake `retrieve`/`semantic_list` stub shape): + +```python +FIXED_NOW = datetime(2026, 7, 11, 12, 0, 0, tzinfo=UTC) + + +def _reflection(id="r1", title="pytest windows redirect", hints=None, + useful_count=0, times_misled=0, confidence=0.8, + updated_at="2026-07-01T00:00:00+00:00", polarity="do"): + return { + "id": id, "title": title, "phase": "general", "use_cases": "", + "hints": hints or [], "confidence": confidence, "tech": None, + "evidence_count": 1, "useful_count": useful_count, + "times_misled": times_misled, "updated_at": updated_at, + "_polarity": polarity, # test helper only; buckets carry polarity + } + + +def test_floor_rejects_single_hit_by_default(fake_backend): + fake_backend.reflections = {"do": [_reflection(title="pytest only-one")], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="pytest something unrelated", + project="p", now=lambda: FIXED_NOW) + assert out == [] # 1 distinct hit < min_hits=2 + + +def test_floor_admits_two_hits(fake_backend): + fake_backend.reflections = {"do": [_reflection(title="pytest windows redirect")], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="run pytest on windows", + project="p", now=lambda: FIXED_NOW) + assert [m.id for m in out] == ["r1"] + assert out[0].hits == 2 + assert out[0].age_days == 10 + assert out[0].kind == "reflection" + + +def test_title_hits_count_double_in_score(fake_backend): + title_match = _reflection(id="rt", title="alpha beta", updated_at="2026-07-01T00:00:00+00:00") + hint_match = _reflection(id="rh", title="zzz yyy", hints=["alpha beta"], updated_at="2026-07-01T00:00:00+00:00") + fake_backend.reflections = {"do": [hint_match, title_match], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", now=lambda: FIXED_NOW) + assert out[0].id == "rt" # same hits, but title-weighted score wins + + +def test_misled_penalty_halves_score(fake_backend): + clean = _reflection(id="rc", title="alpha beta", useful_count=0, times_misled=0) + burned = _reflection(id="rb", title="alpha beta", useful_count=0, times_misled=3) + fake_backend.reflections = {"do": [burned, clean], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", now=lambda: FIXED_NOW) + assert out[0].id == "rc" + assert out[1].score < out[0].score + + +def test_max_items_cap(fake_backend): + fake_backend.reflections = {"do": [ + _reflection(id=f"r{i}", title="alpha beta") for i in range(6) + ], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", + max_items=3, now=lambda: FIXED_NOW) + assert len(out) == 3 + + +def test_missing_metadata_is_neutral(fake_backend): + r = _reflection(id="rm", title="alpha beta") + del r["times_misled"]; del r["updated_at"] # older backend shape + fake_backend.reflections = {"do": [r], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", now=lambda: FIXED_NOW) + assert out[0].age_days is None + assert out[0].score > 0 +``` + +Also keep/adapt the file's existing tests: backend-error-degrades-to-empty, empty-keyword-query returns [] (these already exist — update signatures only). + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/services/test_relevant.py -q` +Expected: FAIL — new kwargs/fields missing. + +- [ ] **Step 3: Implement** — replace `RelevantMemory` and `retrieve_relevant` in `relevant.py`: + +```python +@dataclass +class RelevantMemory: + kind: str # "reflection" | "semantic" + id: str + text: str # full display text (renderer truncates) + polarity: str | None # "do" | "dont" | None for semantic + confidence: float | None + useful_count: int + age_days: int | None + hits: int + score: float + + +def _age_days(iso_ts: str | None, now: datetime) -> int | None: + if not iso_ts: + return None + try: + ts = datetime.fromisoformat(iso_ts) + except ValueError: + return None + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + return max(0, (now - ts).days) + + +def _activation(*, useful_count: int, times_misled: int, confidence: float | None) -> float: + act = (1.0 + 0.2 * math.log1p(max(0, useful_count))) + if confidence is not None: + act *= max(0.1, float(confidence)) + if times_misled > useful_count: + act *= 0.5 + return act + + +def retrieve_relevant( + backend: Any, + *, + query: str, + project: str, + min_hits: int = 2, + max_items: int = 3, + include_neutral: bool = False, + now: Callable[[], datetime] | None = None, +) -> list[RelevantMemory]: + """Score curated memories against the query; return top max_items whose + distinct-keyword hits >= min_hits, ordered by score desc. Never raises.""" + keywords = extract_keywords(query) + if not keywords: + return [] + _now = (now or (lambda: datetime.now(UTC)))() + + out: list[RelevantMemory] = [] + + 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 []: + title = str(r.get("title") or "") + body = " ".join( + [str(r.get("use_cases") or "")] + + [str(h) for h in (r.get("hints") or [])] + ) + title_hits = count_keyword_hits(title, keywords) + total_hits = count_keyword_hits(f"{title} {body}", keywords) + if total_hits < min_hits: + continue + act = _activation( + useful_count=int(r.get("useful_count") or 0), + times_misled=int(r.get("times_misled") or 0), + confidence=r.get("confidence"), + ) + score = (total_hits + title_hits) * act # title hits count double + out.append(RelevantMemory( + kind="reflection", id=str(r.get("id")), + 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=total_hits, score=score, + )) + + 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 < min_hits: + continue + act = _activation( + useful_count=int(getattr(s, "useful_count", 0) or 0), + times_misled=int(getattr(s, "times_misled", 0) or 0), + confidence=None, + ) + out.append(RelevantMemory( + kind="semantic", id=str(getattr(s, "id", "")), + 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=hits, score=hits * act, + )) + + out.sort(key=lambda m: (-m.score, m.id)) + return out[:max_items] +``` + +Imports to add at top: `import math`, `from collections.abc import Callable`, `from datetime import UTC, datetime`. Module docstring: update "whole-word keyword-filters" sentence to describe hits x activation scoring with a min-hits floor. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/services/test_relevant.py tests/hooks/test_contextual_inject.py -q` +Expected: relevant tests PASS. If `test_contextual_inject.py` breaks on `format_relevant`/`retrieve_relevant` signatures, patch minimal call-site compatibility in the test only if trivial — otherwise note it and fix fully in Task 8 (hook rewiring), keeping this commit green by adjusting the hook's `retrieve_relevant(...)` call: `retrieve_relevant(backend, query=query, project=project)` already matches the new defaults; `limit=5` kwarg must be dropped from `contextual_inject.py:77`. + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/services/relevant.py tests/services/test_relevant.py better_memory/hooks/contextual_inject.py +git commit -m "feat(relevant): hits-x-activation scoring with min-hits floor and max-items cap" +``` + +--- + +### Task 5: Injection renderer v2 — `` block + +**Files:** +- Modify: `better_memory/services/relevant.py` (replace `format_relevant`) +- Test: `tests/services/test_relevant_format.py` (new) + +**Interfaces:** +- Consumes: `RelevantMemory` from Task 4. +- Produces: `format_relevant(items: list[RelevantMemory]) -> str` (no `max_items` kwarg — capping happens in retrieve). Consumed by Task 8. + +**Confidence: 93%** + +- [ ] **Step 1: Write failing tests** `tests/services/test_relevant_format.py`: + +```python +from better_memory.services.relevant import RelevantMemory, format_relevant + + +def _mem(**kw): + base = dict(kind="reflection", id="a" * 32, text="Use junit-xml on windows", + polarity="do", confidence=0.9, useful_count=15, age_days=34, + hits=3, score=5.0) + base.update(kw) + return RelevantMemory(**base) + + +def test_empty_items_renders_empty(): + assert format_relevant([]) == "" + + +def test_block_structure_and_full_id(): + out = format_relevant([_mem()]) + assert out.startswith('') + assert out.rstrip().endswith("") + assert "a" * 32 in out # FULL id present + assert "conf 0.9" in out + assert "used 15x" in out + assert "34d old" in out + assert "memory_credit" in out # rating affordance line + assert "'cited'|'shaped'|'misled'" in out + + +def test_dont_polarity_rendered_as_corrective(): + out = format_relevant([_mem(polarity="dont", text="inline INSERT SQL in tests drifts")]) + assert "Known pitfall -- do this instead:" in out + + +def test_semantic_item_without_confidence(): + out = format_relevant([_mem(kind="semantic", polarity=None, confidence=None, + useful_count=0, text="repo uses uv run pytest")]) + assert "conf" not in out.split("\n")[2] # no conf tag on the semantic line + assert "semantic" in out + + +def test_missing_age_omitted(): + out = format_relevant([_mem(age_days=None)]) + assert "d old" not in out + + +def test_output_is_ascii(): + out = format_relevant([_mem()]) + out.encode("ascii") # raises if any non-ASCII slipped in +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/services/test_relevant_format.py -q` +Expected: FAIL — old format has no XML tag / different signature. + +- [ ] **Step 3: Implement** — replace `format_relevant` in `relevant.py`: + +```python +_TEXT_MAX_CHARS = 400 + +_BLOCK_HEADER = ( + '\n' + "Prior knowledge from past sessions in this project " + "(factual records; verify if stale):" +) +_BLOCK_FOOTER = ( + "If any entry above materially helps or misleads this task, credit it now: " + "memory_credit(kind, id, 'cited'|'shaped'|'misled').\n" + "" +) + + +def _meta_tag(m: RelevantMemory) -> str: + parts = [f"{m.kind} {m.id}"] + if m.confidence is not None: + parts.append(f"conf {m.confidence:.1f}") + if m.useful_count: + parts.append(f"used {m.useful_count}x") + if m.age_days is not None: + parts.append(f"{m.age_days}d old") + return "[" + " | ".join(parts) + "]" + + +def format_relevant(items: list[RelevantMemory]) -> str: + """Render the additionalContext block. Empty string if no items.""" + if not items: + return "" + lines = [_BLOCK_HEADER] + for i, m in enumerate(items, start=1): + text = m.text if len(m.text) <= _TEXT_MAX_CHARS else m.text[: _TEXT_MAX_CHARS - 3] + "..." + if m.polarity == "dont": + text = f"Known pitfall -- do this instead: {text}" + lines.append(f"{i}. {_meta_tag(m)}") + lines.append(f" {text}") + lines.append(_BLOCK_FOOTER) + return "\n".join(lines) +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/services/test_relevant_format.py tests/services/test_relevant.py -q` — Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/services/relevant.py tests/services/test_relevant_format.py +git commit -m "feat(relevant): project-memory XML rendering with full ids, ages, dont-flip, rating affordance" +``` + +--- + +### Task 6: Per-session seen-file dedup module + +**Files:** +- Create: `better_memory/services/context_seen.py` +- Test: `tests/services/test_context_seen.py` + +**Interfaces:** +- Produces: + - `SeenStore(state_dir: Path, session_id: str)` with methods + `bump_turn() -> int` (increments + persists turn counter, returns new turn), + `filter_unseen(ids: list[tuple[str, str]], *, reinject_turns: int) -> list[tuple[str, str]]` ((kind,id) pairs), + `mark_seen(ids: list[tuple[str, str]]) -> None`, + - module function `prune_stale(state_dir: Path, *, now: datetime, max_age_days: int = 7) -> None`. + Consumed by Task 8. +- File format: `context_seen_.json` = `{"turn": int, "seen": {":": last_injected_turn}}`. + +**Confidence: 92%** + +- [ ] **Step 1: Write failing tests** `tests/services/test_context_seen.py`: + +```python +from datetime import UTC, datetime +from pathlib import Path + +from better_memory.services.context_seen import SeenStore, prune_stale + + +def _store(tmp_path): + return SeenStore(tmp_path, "sess-1") + + +def test_first_exposure_passes_then_suppressed(tmp_path): + s = _store(tmp_path) + s.bump_turn() + ids = [("reflection", "r1"), ("semantic", "m1")] + assert s.filter_unseen(ids, reinject_turns=0) == ids + s.mark_seen(ids) + s2 = _store(tmp_path) # fresh instance = fresh hook process + s2.bump_turn() + assert s2.filter_unseen(ids, reinject_turns=0) == [] + + +def test_reinject_after_n_turns(tmp_path): + s = _store(tmp_path) + s.bump_turn() + s.mark_seen([("reflection", "r1")]) + for _ in range(3): + s2 = _store(tmp_path) + s2.bump_turn() + s3 = _store(tmp_path) + s3.bump_turn() # turn 5; injected at turn 1 + assert s3.filter_unseen([("reflection", "r1")], reinject_turns=3) == [("reflection", "r1")] + assert s3.filter_unseen([("reflection", "r1")], reinject_turns=10) == [] + + +def test_corrupt_file_treated_as_empty(tmp_path): + (tmp_path / "context_seen_sess-1.json").write_text("{not json", encoding="utf-8") + s = _store(tmp_path) + assert s.bump_turn() == 1 + assert s.filter_unseen([("reflection", "r1")], reinject_turns=0) == [("reflection", "r1")] + + +def test_sessions_are_isolated(tmp_path): + a = SeenStore(tmp_path, "sess-a") + a.bump_turn() + a.mark_seen([("reflection", "r1")]) + b = SeenStore(tmp_path, "sess-b") + b.bump_turn() + assert b.filter_unseen([("reflection", "r1")], reinject_turns=0) == [("reflection", "r1")] + + +def test_prune_stale_removes_old_files_only(tmp_path): + import os + old = tmp_path / "context_seen_old.json" + new = tmp_path / "context_seen_new.json" + old.write_text("{}", encoding="utf-8") + new.write_text("{}", encoding="utf-8") + ten_days_ago = datetime(2026, 7, 1, tzinfo=UTC).timestamp() + os.utime(old, (ten_days_ago, ten_days_ago)) + prune_stale(tmp_path, now=datetime(2026, 7, 11, tzinfo=UTC)) + assert not old.exists() + assert new.exists() + + +def test_missing_state_dir_never_raises(tmp_path): + s = SeenStore(tmp_path / "does-not-exist-yet", "sess-1") + assert s.bump_turn() == 1 # creates the dir + s.mark_seen([("reflection", "r1")]) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/services/test_context_seen.py -q` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** `better_memory/services/context_seen.py`: + +```python +"""Per-session seen-store for contextual memory injection dedup. + +Backend-independent (works in agentcore mode where there is no exposure +table) and cheap: one small JSON file per session under +``/state``. Never raises: corrupt or unwritable state +degrades to "nothing seen". + +File format: ``context_seen_.json`` -> +``{"turn": int, "seen": {":": last_injected_turn}}``. +""" +from __future__ import annotations + +import json +import re +from datetime import datetime +from pathlib import Path + +_FILE_RE = re.compile(r"^context_seen_.+\.json$") +_SAFE_SESSION_RE = re.compile(r"[^A-Za-z0-9_.-]") + + +def _key(kind: str, id_: str) -> str: + return f"{kind}:{id_}" + + +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._path = state_dir / f"context_seen_{safe}.json" + self._data = self._load() + + 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"]} + except BaseException: # noqa: BLE001 - corrupt/missing -> empty + pass + return {"turn": 0, "seen": {}} + + def _save(self) -> None: + try: + self._dir.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(self._data), encoding="utf-8") + except BaseException: # noqa: BLE001 - best-effort + pass + + def bump_turn(self) -> int: + self._data["turn"] = int(self._data.get("turn") or 0) + 1 + self._save() + return self._data["turn"] + + def filter_unseen( + self, ids: list[tuple[str, str]], *, reinject_turns: int, + ) -> list[tuple[str, str]]: + turn = int(self._data.get("turn") or 0) + out: list[tuple[str, str]] = [] + for kind, id_ in ids: + last = self._data["seen"].get(_key(kind, id_)) + if last is None: + out.append((kind, id_)) + elif reinject_turns > 0 and (turn - int(last)) > reinject_turns: + out.append((kind, id_)) + return out + + def mark_seen(self, ids: list[tuple[str, str]]) -> None: + turn = int(self._data.get("turn") or 0) + for kind, id_ in ids: + self._data["seen"][_key(kind, id_)] = turn + 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.""" + try: + cutoff = now.timestamp() - max_age_days * 86400 + for f in state_dir.iterdir(): + if _FILE_RE.match(f.name) and f.stat().st_mtime < cutoff: + f.unlink(missing_ok=True) + except BaseException: # noqa: BLE001 + pass +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/services/test_context_seen.py -q` — Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/services/context_seen.py tests/services/test_context_seen.py +git commit -m "feat(context): per-session seen-store for contextual injection dedup" +``` + +--- + +### Task 7: `record_exposures` protocol method (sqlite insert / agentcore no-op) + +**Files:** +- Modify: `better_memory/storage/protocol.py` (new method after `session_bootstrap`, ~line 241) +- Modify: `better_memory/services/session_bootstrap.py` (generalise `_record_exposure` into public `record_exposures`) +- Modify: `better_memory/storage/sqlite.py` (delegate) +- Modify: `better_memory/storage/agentcore.py` (no-op) +- Test: `tests/services/test_exposure_tracking.py` (extend), `tests/storage/test_protocol.py` (extend), `tests/storage/test_agentcore_unit.py` (extend) + +**Interfaces:** +- Produces: `record_exposures(*, session_id: str, items: list[tuple[str, str]], source: str) -> None` on the protocol and both backends. `items` = `(kind, id)` pairs, kind in `{"reflection","semantic"}`. Consumed by Task 8. + +**Confidence: 92%** + +- [ ] **Step 1: Write failing tests.** In `tests/services/test_exposure_tracking.py` (reuse its existing seeded-conn fixture idiom): + +```python +def test_record_exposures_contextual_source(seeded_conn): + svc = SessionBootstrapService(seeded_conn) + svc.record_exposures( + session_id="s-ctx", + items=[("reflection", "r1"), ("semantic", "m1")], + source="contextual", + ) + rows = seeded_conn.execute( + "SELECT memory_kind, memory_id, source FROM session_memory_exposure " + "WHERE session_id='s-ctx' ORDER BY memory_kind" + ).fetchall() + assert [(r["memory_kind"], r["memory_id"], r["source"]) for r in rows] == [ + ("reflection", "r1", "contextual"), + ("semantic", "m1", "contextual"), + ] + + +def test_record_exposures_empty_session_id_is_noop(seeded_conn): + svc = SessionBootstrapService(seeded_conn) + svc.record_exposures(session_id="", items=[("reflection", "r1")], source="contextual") + n = seeded_conn.execute( + "SELECT COUNT(*) AS n FROM session_memory_exposure" + ).fetchone()["n"] + assert n == 0 +``` + +In `tests/storage/test_protocol.py`, extend the conformance check so both backends expose `record_exposures` (follow the file's existing method-presence pattern). In `tests/storage/test_agentcore_unit.py`: + +```python +def test_record_exposures_is_noop(agentcore_backend): + agentcore_backend.record_exposures( + session_id="s", items=[("reflection", "r1")], source="contextual", + ) # must not raise, must not call any client API (assert no boto calls if the fixture records them) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/services/test_exposure_tracking.py tests/storage/ -q` +Expected: FAIL — `record_exposures` missing. + +- [ ] **Step 3: Implement service method** in `session_bootstrap.py` — replace the body of `_record_exposure` with a call to the new public method: + +```python + def record_exposures( + self, + *, + session_id: str, + items: list[tuple[str, str]], + source: str, + ) -> None: + """Write one session_memory_exposure row per (kind, id) item. + + Best-effort: skips entirely when session_id is empty. Own commit + (see module docstring on connection ownership). + """ + if not session_id or not items: + return + now = self._clock().isoformat() + self._conn.executemany( + "INSERT OR IGNORE INTO session_memory_exposure " + "(session_id, memory_kind, memory_id, exposed_at, source) " + "VALUES (?, ?, ?, ?, ?)", + [(session_id, kind, mid, now, source) for kind, mid in items], + ) + self._conn.commit() + + def _record_exposure( + self, + *, + session_id: str, + reflection_ids: list[str], + semantic_ids: list[str], + ) -> None: + self.record_exposures( + session_id=session_id, + items=[("reflection", rid) for rid in reflection_ids] + + [("semantic", sid) for sid in semantic_ids], + source="bootstrap", + ) +``` + +- [ ] **Step 4: Protocol + backends.** `protocol.py` (after `session_bootstrap`): + +```python + def record_exposures( + self, + *, + session_id: str, + items: list[tuple[str, str]], + source: str, + ) -> None: + """Record (kind, id) memory exposures for later rating. Sqlite writes + session_memory_exposure rows; agentcore is a documented no-op (it has + no exposure log — rating flows through credit_one).""" + ... +``` + +`sqlite.py` (after `session_bootstrap`): + +```python + def record_exposures( + self, + *, + session_id: str, + items: list[tuple[str, str]], + source: str, + ) -> None: + self._session_bootstrap.record_exposures( + session_id=session_id, items=items, source=source, + ) +``` + +`agentcore.py` (after `session_bootstrap`, before `list_session_exposures`): + +```python + def record_exposures( + self, + *, + session_id: str, + items: list[tuple[str, str]], + source: str, + ) -> None: + """No-op: agentcore mode has no exposure log (see list_session_exposures).""" +``` + +- [ ] **Step 5: Run tests** + +Run: `uv run pytest tests/services/test_exposure_tracking.py tests/storage/ tests/services/test_session_bootstrap.py tests/hooks/test_session_bootstrap.py -q` +Expected: PASS (bootstrap exposure behaviour unchanged via delegation). + +- [ ] **Step 6: Commit** + +```bash +git add better_memory/storage/protocol.py better_memory/storage/sqlite.py better_memory/storage/agentcore.py better_memory/services/session_bootstrap.py tests/services/test_exposure_tracking.py tests/storage/test_protocol.py tests/storage/test_agentcore_unit.py +git commit -m "feat(storage): record_exposures protocol method (sqlite insert, agentcore no-op)" +``` + +--- + +### Task 8: Rewire `contextual_inject` hook — session_id, floor/cap config, dedup, exposures, diagnostics + +**Files:** +- Modify: `better_memory/hooks/contextual_inject.py` +- Test: `tests/hooks/test_contextual_inject.py` (extend) + +**Interfaces:** +- Consumes: Tasks 1 (config), 4/5 (retrieve/format), 6 (SeenStore), 7 (record_exposures). +- Produces: final hook behaviour; envelope shape unchanged (`hookSpecificOutput.additionalContext`). + +**Confidence: 90%** + +- [ ] **Step 1: Write failing tests** (extend `tests/hooks/test_contextual_inject.py`; follow its existing run-hook-via-monkeypatched-stdin idiom — read the file first and reuse its helpers; seed memories through the same fixtures the existing tests use): + +Test list (each is a distinct test function): +1. `test_injection_renders_project_memory_block` — seeded matching reflection (two-keyword title), prompt hits it, stdout JSON `additionalContext` starts with ` None: + """Best-effort observability counter. Sqlite mode only; never raises.""" + if cfg.storage_backend != "sqlite": + return + try: + conn.execute( + "UPDATE rating_diagnostics SET value = value + 1, updated_at = ? " + "WHERE metric = ?", + (datetime.now(UTC).isoformat(), metric), + ) + conn.commit() + except BaseException: # noqa: BLE001 + pass +``` + +New imports: `from datetime import UTC, datetime`, `from better_memory.services.context_seen import SeenStore, prune_stale`. Update the module docstring (mode gating + new floor/dedup/exposure behaviour, PreToolUse reliability note stays). + +Note: `contextual_suppressed_floor` also increments when the query had no keywords or nothing matched at all — that is fine (it means "no injection because nothing cleared the relevance bar"). + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/hooks/test_contextual_inject.py tests/hooks/ -q` — Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/hooks/contextual_inject.py tests/hooks/test_contextual_inject.py +git commit -m "feat(hooks): contextual_inject floor + dedup + exposure tracking + diagnostics" +``` + +--- + +### Task 9: Bootstrap slimming — top-N full render + index + affordance + full ids + ages + +**Files:** +- Modify: `better_memory/services/session_bootstrap.py` (render helpers + `bootstrap()`) +- Modify: `better_memory/hooks/session_bootstrap.py` ONLY if it passes render kwargs (it does not — no change expected) +- Test: `tests/services/test_session_bootstrap.py` (extend), `tests/hooks/test_session_bootstrap.py` (spot-check envelope unchanged) + +**Interfaces:** +- Consumes: `Config.bootstrap_top_n` (Task 1). `SessionBootstrapService.__init__` gains `top_n: int | None = None` kwarg (None -> read from `get_config().bootstrap_top_n` at bootstrap() time so tests can inject). +- Produces: new rendering; `BootstrapResult` unchanged; exposure rows only for fully-rendered items. + +**Confidence: 90%** + +- [ ] **Step 1: Write failing tests** (extend `tests/services/test_session_bootstrap.py`, reusing its seed helpers; add kwargs to helpers if a scope/updated_at knob is missing per [[1ad537fd]]): + +Test list: +1. `test_top_n_limits_full_renders` — seed 8 project semantic + 8 do-reflections, `top_n=2`: exactly 2 semantic full lines (`- []`), 2 reflection blocks (`_id: ...`), and the rest appear as index lines under an `### Index` heading (assert on a seeded title appearing exactly once, in the index section, without its id). +2. `test_general_semantic_always_full` — seed 3 general-scope + 3 project-scope semantic, `top_n=1`: all 3 general render full; only 1 project-scope renders full. +3. `test_full_ids_never_truncated` — full 32-char semantic id present in output; the 8-char prefix-only form (`[abcd1234]` for a longer id) absent. +4. `test_age_stamp_present` — seeded `updated_at` 10 days before injected clock: `(10d old)` in the full-render line. +5. `test_affordance_footer_counts` — with 6 indexed items: footer contains `6 more memories are indexed above` and names `memory_retrieve`. +6. `test_top_n_zero_is_legacy_full_dump` — `top_n=0`: every seeded memory fully rendered, no `### Index` section. +7. `test_exposures_only_for_full_renders` — with `top_n=1`: `session_memory_exposure` contains rows only for the fully-rendered ids. + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/services/test_session_bootstrap.py -q` — Expected: FAIL. + +- [ ] **Step 3: Implement.** In `services/session_bootstrap.py`: + +Constructor + age helper: + +```python + def __init__( + self, + conn, + *, + clock: Callable[[], datetime] | None = None, + top_n: int | None = None, + ) -> None: + self._conn = conn + self._clock: Callable[[], datetime] = clock or (lambda: datetime.now(UTC)) + self._episodes = EpisodeService(conn) + self._top_n = top_n +``` + +```python +def _age_suffix(iso_ts: str | None, now: datetime) -> str: + if not iso_ts: + return "" + try: + ts = datetime.fromisoformat(iso_ts) + except ValueError: + return "" + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + return f" ({max(0, (now - ts).days)}d old)" +``` + +Render changes inside `bootstrap()` (after the buckets/semantic fetch, replacing the current section assembly): + +```python + if self._top_n is not None: + top_n = self._top_n + else: + from better_memory.config import get_config + top_n = get_config().bootstrap_top_n + + now = self._clock() + + if top_n == 0: + sem_full, sem_index = list(semantic), [] + else: + general = [m for m in semantic if m.scope == "general"] + project_scoped = [m for m in semantic if m.scope != "general"] + sem_full = general + project_scoped[:top_n] + sem_index = project_scoped[top_n:] + + flat_reflections = ( + [("do", r) for r in buckets["do"]] + + [("dont", r) for r in buckets["dont"]] + + [("neutral", r) for r in buckets["neutral"]] + ) + if top_n == 0: + refl_full, refl_index = flat_reflections, [] + else: + refl_full = flat_reflections[:top_n] + refl_index = flat_reflections[top_n:] +``` + +New render helpers (replace `_render_semantic` / keep `_render_reflection_bucket` for the full items, grouped by polarity of the survivors): + +```python +def _render_semantic_full(items, now: datetime) -> tuple[str, list[str]]: + if not items: + return "", [] + lines = [f"### Semantic memories ({len(items)} shown in full)"] + ids: list[str] = [] + for m in items: + lines.append(f"- [{m.id}]{_age_suffix(m.updated_at, now)} {_truncate(m.content)}") + ids.append(m.id) + return "\n".join(lines), ids + + +def _render_reflections_full(pairs, now: datetime) -> tuple[str, list[str]]: + """pairs: list of (polarity, reflection-dict) already capped to top-N.""" + if not pairs: + return "", [] + blocks: list[str] = [] + ids: list[str] = [] + for polarity, item in pairs: + label = {"do": "do", "dont": "dont (pitfall - do the corrective action)", + "neutral": "neutral"}[polarity] + lines = [ + f"**{item['title']}** [{label}]" + f"{_age_suffix(item.get('updated_at'), now)}", + f"_{item['use_cases']}_", + ] + for hint in item.get("hints", []): + lines.append(f"- {_truncate(hint)}") + lines.append(f"_id: {item['id']}_") + blocks.append("\n".join(lines)) + ids.append(item["id"]) + return "### Reflections (shown in full)\n" + "\n\n".join(blocks), ids + + +def _render_index(sem_index, refl_index) -> tuple[str, int]: + n = len(sem_index) + len(refl_index) + if n == 0: + return "", 0 + lines = ["### Index (not expanded - retrieve on demand)"] + for polarity, item in refl_index: + conf = item.get("confidence") + conf_s = f", conf {conf:.1f}" if isinstance(conf, (int, float)) else "" + lines.append(f"- {item['title']} ({polarity}{conf_s})") + for m in sem_index: + first_line = (m.content or "").splitlines()[0][:100] + lines.append(f"- {first_line} (semantic)") + return "\n".join(lines), n +``` + +Section assembly + footer: + +```python + sections = [_render_header(...)] # unchanged header call + sem_section, semantic_ids = _render_semantic_full(sem_full, now) + if sem_section: + sections.append(sem_section) + refl_section, reflection_ids = _render_reflections_full(refl_full, now) + if refl_section: + sections.append(refl_section) + index_section, index_count = _render_index(sem_index, refl_index) + if index_section: + sections.append(index_section) + sections.append("---") + footer = _FOOTER + if index_count: + footer = ( + f"{index_count} more memories are indexed above - call " + "mcp__better-memory__memory_retrieve or " + "mcp__better-memory__memory_retrieve_observations when a task " + "touches them.\n" + _FOOTER + ) + sections.append(footer) + rendered = "\n\n".join(sections) + + self._record_exposure( + session_id=session_id, + reflection_ids=reflection_ids, + semantic_ids=semantic_ids, + ) +``` + +Notes: reflection dicts carry `updated_at` after Task 3. Semantic full-render fixes the 8-char truncation bug (`m.id`, not `m.id[:8]`). `BootstrapResult` counts stay as the RETRIEVED counts (unchanged semantics: what exists, not what rendered) — do not change count fields. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/services/test_session_bootstrap.py tests/hooks/test_session_bootstrap.py tests/mcp/ -q` +Expected: PASS. Fix any MCP bootstrap handler test that asserted the old semantic `[:8]` rendering. + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/services/session_bootstrap.py tests/services/test_session_bootstrap.py tests/hooks/test_session_bootstrap.py +git commit -m "feat(bootstrap): top-N full render + index + retrieve affordance; full ids and age stamps" +``` + +--- + +### Task 10: Stop-hook directive — per-source labels and counts + +**Files:** +- Modify: `better_memory/hooks/session_close.py:138-184` (`_emit_rating_directive_if_unrated`) +- Test: `tests/hooks/test_session_close_rating_directive.py` (extend) + +**Interfaces:** +- Consumes: `session_memory_exposure.source` values including `'contextual'` (Task 2). +- Produces: directive lines formatted `- []: `; header gains per-source counts. + +**Confidence: 93%** + +- [ ] **Step 1: Write failing test** (extend the existing directive test file, reusing its seeding helpers; add a `source=` kwarg to the seed helper if absent): + +```python +def test_directive_shows_source_labels_and_counts(seeded_session): + # seed: one bootstrap reflection exposure, one contextual semantic exposure + out = run_stop_hook(seeded_session) # existing helper idiom + directive = out["hookSpecificOutput"]["additionalContext"] + assert "[bootstrap]" in directive + assert "[contextual]" in directive + assert "sources: bootstrap 1, contextual 1" in directive +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest tests/hooks/test_session_close_rating_directive.py -q` — Expected: FAIL. + +- [ ] **Step 3: Implement.** In `_emit_rating_directive_if_unrated`: + - Add `MIN(e.source) AS source` to the SELECT column list (after `MIN(e.exposed_at) AS exposed_at`). + - Line rendering becomes: + +```python + source_counts: dict[str, int] = {} + for r in rows: + display = (r["display"] or "")[:TRUNC] + source = r["source"] or "bootstrap" + source_counts[source] = source_counts.get(source, 0) + 1 + line = f"- {r['memory_id']} [{source}]: {display}" + if r["memory_kind"] == "reflection": + refl_lines.append(line) + else: + sem_lines.append(line) + counts_line = "sources: " + ", ".join( + f"{k} {v}" for k, v in sorted(source_counts.items()) + ) +``` + + - Insert `counts_line` into the directive right after the first sentence: + +```python + directive = ( + "RATE_MEMORIES - before this session ends, classify the " + "memories that were exposed during this session and that you " + "did NOT already credit via memory.credit.\n" + f"({counts_line})\n\n" + ... # remainder unchanged + ) +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/hooks/test_session_close_rating_directive.py tests/hooks/test_session_close_agentcore.py -q` — Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add better_memory/hooks/session_close.py tests/hooks/test_session_close_rating_directive.py +git commit -m "feat(hooks): rating directive shows per-source labels and counts" +``` + +--- + +### Task 11: E2E integration test — contextual injection to rating credit + +**Files:** +- Test: `tests/integration/test_contextual_rating_e2e.py` (new; marked `integration` per the pattern in `tests/integration/test_memory_rating_e2e.py` — read that file first and mirror its fixtures/marks) + +**Interfaces:** consumes everything above; no production code. + +**Confidence: 90%** + +- [ ] **Step 1: Write the test** (single test, full loop, sqlite backend): + +```python +@pytest.mark.integration +def test_contextual_injection_full_rating_loop(migrated_conn, monkeypatch, tmp_path): + """contextual exposure -> list_session_exposures includes it -> + apply_session_ratings('cited') bumps useful_count -> next retrieval ranks it up.""" + # 1. Seed one reflection whose title shares >= 2 keywords with the query. + # 2. Build SqliteBackend(session_id='e2e-sess', project='proj'). + # 3. items = retrieve_relevant(backend, query='pytest windows redirect', project='proj') + # assert the seeded reflection is in items. + # 4. backend.record_exposures(session_id='e2e-sess', + # items=[(m.kind, m.id) for m in items], source='contextual') + # 5. exposures = backend.list_session_exposures(session_id='e2e-sess') + # assert the id present with source == 'contextual'. + # 6. backend.apply_session_ratings(session_id='e2e-sess', + # ratings=[{'kind': 'reflection', 'id': , 'class': 'cited'}]) + # 7. assert reflections.useful_count == 1 for that id (direct SELECT). +``` + +Write the real code following `test_memory_rating_e2e.py`'s seeding idiom — every numbered step above becomes real statements, no placeholders in the committed test. + +- [ ] **Step 2: Run** + +Run: `uv run pytest tests/integration/test_contextual_rating_e2e.py -m integration -q` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/test_contextual_rating_e2e.py +git commit -m "test(integration): contextual injection to rating credit e2e loop" +``` + +--- + +### Task 12: Docs sweep + +**Files:** +- Modify: `README.md`, `website/configuration.md`, `website/architecture.md`, `website/observation-lifecycle.md`, `website/mcp-tools.md` (only if tool docs mention exposure sources), `website/index.md` (only if counts/claims drift) + +**Confidence: 95%** + +- [ ] **Step 1: Update docs.** + - `website/configuration.md` env-var table: add the four new vars with defaults + one-line meanings (exact names from Global Constraints). + - `website/architecture.md`: contextual injection section — describe floor, dedup seen-file, `` block, `source='contextual'` exposures; bootstrap section — top-N + index + affordance. + - `website/observation-lifecycle.md`: rating loop now covers contextual exposures; directive shows per-source labels. + - `README.md`: env-var mentions + any description of the contextual hook / bootstrap dump; verify no stale claims about "all semantic memories injected". + - Verify every factual token (env var names, defaults, table/column names, metric names) against the code written in Tasks 1-10 — do not carry forward from this plan without checking. +- [ ] **Step 2: Full suite + lint** + +Run: `uv run pytest -q > pytest_final.txt 2>&1` then read the tail; and `uv run ruff check .` +Expected: all green. + +- [ ] **Step 3: Commit** + +```bash +git add README.md website/ +git commit -m "docs: attention-first injection - env vars, contextual flow, bootstrap slimming" +``` + +--- + +## Self-review notes + +- Spec coverage: C1=Task 9, C2=Tasks 3+4, C3=Task 5, C4=Task 6, C5=Tasks 2+7+8, C6=Tasks 8 (diagnostics) + 10 (directive) + 5 (affordance line), C7=Tasks 1+12, E2E=Task 11. Out-of-scope items untouched. +- Type consistency: `record_exposures(*, session_id, items: list[tuple[str, str]], source)` identical in Tasks 7 and 8; `retrieve_relevant(backend, *, query, project, min_hits, max_items, include_neutral, now)` identical in Tasks 4, 8, 11; `format_relevant(items)` (no max_items) in Tasks 5 and 8; `SeenStore(state_dir, session_id)` in Tasks 6 and 8; config field names in Tasks 1, 8, 9. +- Known judgment calls implementers must NOT re-litigate: floor counts DISTINCT keyword hits; title hits add (not multiply) into score; `dont` items render with the literal prefix `Known pitfall -- do this instead: `; index items get no ids; diagnostics are sqlite-only. diff --git a/docs/superpowers/specs/2026-07-11-attention-first-injection-design.md b/docs/superpowers/specs/2026-07-11-attention-first-injection-design.md new file mode 100644 index 0000000..2c189af --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-attention-first-injection-design.md @@ -0,0 +1,227 @@ +# Attention-First Injection Redesign — Design + +Date: 2026-07-11 +Status: approved (user picks: R1=A, R2=A) +Branch: feat/attention-first-injection + +## Problem + +better-memory injects memories into running Claude Code sessions, but the LLM +ignores them. Root causes identified in code and in episodic-memory literature: + +1. **Bootstrap dumps everything at top-of-context.** `SessionBootstrapService` + renders ALL semantic memories (uncapped) plus up to 20x3 reflections at + SessionStart — the "lost middle" position whose influence degrades as the + session grows. Injected semantic ids are truncated to 8 chars, so they + cannot be rated even in principle. +2. **Contextual injections are unrateable.** The `contextual_inject` hook + (PR #76) retrieves with `track_exposure=False`, writes no + `session_memory_exposure` rows, and renders no memory ids. The rating loop + never sees the memories most likely to matter. +3. **No relevance floor.** `retrieve_relevant` admits any memory with >= 1 + whole-word keyword hit. Marginal matches are noise, and noise trains the + model to skim the injection block. +4. **No dedup.** The hook re-runs on every UserPromptSubmit and matched + PreToolUse with no per-session suppression; repeated identical injections + are documented (Claude Code issue tracker) to consume context and get + skimmed. +5. **Weak formatting.** A bare `RELEVANT MEMORY — ...` line with `•` bullets: + no XML tag, no ids, no age, no provenance framing, `dont` items as raw + prohibitions. + +Best-practice anchors (research 2026-07-11): inject per-turn at the recency +end of context (UserPromptSubmit `additionalContext`); 3–5 items max plus an +index/affordance for the rest; precision over recall (inject nothing below a +relevance floor); distinct XML tag with factual — not imperative — phrasing; +timestamp every memory; the Stop hook decision-block is the one place an +imperative rating directive reliably produces tool calls (better-memory +already does this); pair explicit ratings with implicit diagnostics. + +## Constraints + +- Relevance scoring must stay **backend-agnostic**: pure-Python over + `StorageBackend.retrieve()` / `.semantic_list()` results so it works on both + SqliteBackend and AgentCoreBackend. No embeddings, no FTS5 on the hot path. +- Hooks keep the never-raise contract; UserPromptSubmit path stays well under + 1s; failure yields empty context plus a `hook_errors` row. +- AgentCore mode has no exposure log by spec (`list_session_exposures` returns + an empty envelope); rating there flows through `credit_one`. + +## Components + +### C1 — Bootstrap slimming (`better_memory/services/session_bootstrap.py`) + +- Render **top-N reflections** (across do/dont, existing + `useful_count + 3*times_overlooked` ranking) and **top-N project-scope + semantic memories** in full, each with **full id** and an age stamp + ("recorded 34d ago"). N default 5 per kind, `BETTER_MEMORY_BOOTSTRAP_TOP_N`. +- **General-scope semantic memories always render in full** (user pick R2=A): + they are few and behavioural (workflow rules). +- Every non-rendered memory gets a **one-line index entry**: + `- title-or-first-line (do|dont|semantic, conf X)` — nothing becomes + invisible, it just stops consuming attention. +- Affordance footer: "N more memories are indexed above — call + memory_retrieve / memory_retrieve_observations when a task touches them." +- Exposure rows written **only for fully-rendered items** (index lines are not + exposures). +- `BETTER_MEMORY_BOOTSTRAP_TOP_N=0` preserves current full-dump behaviour + (escape hatch). +- Fix the existing bug: semantic ids render full, never truncated to 8 chars. + +### C2 — Relevance scoring v2 (`better_memory/services/relevant.py`) + +- Source data unchanged: `backend.retrieve(track_exposure=False)` + + `backend.semantic_list(track_exposure=False)`. +- Score per candidate: `hit_score * activation` where + - `hit_score` = distinct keyword hits; hits in the reflection **title** + count double (title is the distilled signal). + - `activation` = `(1 + 0.2*ln(1+useful_count)) * confidence_weight`, + `confidence_weight` = confidence when present else 1.0; multiply by 0.5 + when `times_misled > useful_count` (known-misleading penalty). All fields + already returned by both backends; missing fields default neutral. +- **Relevance floor**: candidate needs >= `BETTER_MEMORY_CONTEXT_MIN_HITS` + (default 2) distinct keyword hits. Below floor for all candidates → inject + nothing (empty string, no envelope). +- Cap: `BETTER_MEMORY_CONTEXT_MAX_ITEMS` (default 3). +- `RelevantMemory` gains fields needed by the renderer: `kind, id, text, + confidence, useful_count, age_days, polarity, score`. + +### C3 — Injection format v2 (renderer in `relevant.py`) + +``` + +Prior knowledge from past sessions in this project (factual records; verify if stale): +1. [reflection 6aa6bf51e7e94a4a931358605521aed6 | conf 0.9 | used 15x | 34d old] + Prioritize root-cause fixing: +2. [semantic 29f762c9d1e24a52... | 2d old] + <content, truncated at 400 chars> +If any entry above materially helps or misleads this task, credit it now: +memory_credit(kind, id, 'cited'|'shaped'|'misled'). +</project-memory> +``` + +- Distinct XML tag; factual framing (hooks docs: imperative "system command" + phrasing can trip prompt-injection defenses). +- **Full ids always** — the rating tools require them. +- `dont`-polarity reflections rendered positively: "Do <inverse> instead + (previously failed: <hint>)" — flipping prohibitions to do-this guidance + measurably improves compliance. When no inverse is derivable from the + title/hints, fall back to "Known pitfall: <text>". +- Age stamp on every item (`age_days` from `updated_at` / `created_at`). +- Closing one-line `memory_credit` affordance (C6). + +### C4 — Per-session dedup (hook-side, backend-independent) + +- Seen-file: `~/.better-memory/state/context_seen_<session_id>.json` holding + `{memory_id: {last_injected_turn, count}}` plus a monotonically increasing + turn counter incremented on each hook invocation. +- A memory is contextually injected at most once per session; re-eligible only + after `BETTER_MEMORY_CONTEXT_REINJECT_TURNS` (default 0 = never re-inject) + turns since last injection. +- Corrupt or missing file → treated as empty; write failures swallowed. +- Files older than 7 days pruned opportunistically on hook start. +- No DB round-trip; works identically in AgentCore mode. + +### C5 — Contextual exposure tracking + +- New `StorageBackend` protocol method: + `record_exposures(*, session_id: str, items: list[tuple[str, str]], source: str) -> None` + (items = `(kind, id)` pairs). + - SqliteBackend: `INSERT OR IGNORE` into `session_memory_exposure` with + `source='contextual'`; own commit; missing session_id → no-op + bump + `rating_diagnostics.session_id_missing`. + - AgentCoreBackend: documented no-op (consistent with its empty + `list_session_exposures` contract). +- Migration `0011`: widen the `source` CHECK constraint to include + `'contextual'` using the table-recreation pattern. Includes a + data-preservation round-trip test (seed pre-migration row, migrate, assert + every column value survives). +- `contextual_inject` calls `record_exposures` AFTER rendering; failures are + swallowed and logged — exposure write must never block injection. +- Result: contextual injections appear in `list_session_exposures` and the + existing Stop-hook RATE_MEMORIES directive covers them with no further + plumbing. + +### C6 — Rating loop strengthening + +- Stop-hook directive (`session_close.py`) mechanics unchanged (decision:block + at Stop validated by research). Text changes: + - per-memory injection source shown (bootstrap / contextual / retrieve); + - per-source exposure counts in the header line. +- Inline affordance line in every `<project-memory>` block (C3). +- Diagnostics (R1=A): `rating_diagnostics` counters — + `contextual_fired_userprompt`, `contextual_fired_pretool`, + `contextual_injected`, `contextual_suppressed_floor`, + `contextual_suppressed_dedup` — so injected-vs-rated and + UserPromptSubmit-vs-PreToolUse fire rates are observable. +- Default `BETTER_MEMORY_CONTEXT_INJECT_MODE` stays `both` (R1=A). +- No automatic decay in this phase (YAGNI; explicit ratings + existing + overlooked-weighting already feed ranking). + +### C7 — Config + docs sync + +- New env vars in `config.py`, validated like `CONTEXT_INJECT_MODE`: + +| Var | Default | Meaning | +|---|---|---| +| `BETTER_MEMORY_BOOTSTRAP_TOP_N` | `5` | Full-render count per kind at bootstrap; `0` = legacy full dump | +| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | Relevance floor (distinct keyword hits) | +| `BETTER_MEMORY_CONTEXT_MAX_ITEMS` | `3` | Max memories per contextual injection | +| `BETTER_MEMORY_CONTEXT_REINJECT_TURNS` | `0` | Turns before a seen memory may re-inject; `0` = never | + +- Docs sweep (project reflection "keep website and README in sync"): + README.md, website/configuration.md (env-var table), website/mcp-tools.md, + website/architecture.md, website/observation-lifecycle.md, module + docstrings that enumerate config knobs. + +## Data flow (one prompt) + +1. UserPromptSubmit fires → `contextual_inject` builds query from prompt text. +2. `retrieve_relevant`: fetch curated sets via backend → score (C2) → floor. +3. Dedup against seen-file (C4). +4. Survivors: render `<project-memory>` block (C3), `record_exposures` + (C5, swallow failures), mark seen, emit `additionalContext`. +5. No survivors: emit nothing. +6. At Stop: RATE_MEMORIES directive lists all unrated exposures including + contextual ones → `rate-session-memories` skill → + `apply_session_ratings` → `useful_count`/`times_misled` feed the next + session's ranking (C2 activation). + +## Error handling + +- Hooks never raise; every failure path yields empty context and a + `hook_errors` row. +- Seen-file corrupt → empty; write failure → swallowed. +- Exposure write failure → swallowed + logged, injection still emitted. +- Latency: one seen-file read/write added; scoring in-memory over already + fetched lists. No embeddings, no new DB queries on the hot path beyond the + exposure INSERT (post-render). + +## Testing + +- **C1**: slim render (top-N + index + affordance + ages + full ids), + general-scope semantic always full, `TOP_N=0` legacy behaviour, exposure + rows only for full renders. +- **C2**: scoring (title double-weight, activation multipliers, misled + penalty), floor boundary (1 hit rejected at default, 2 accepted), + MIN_HITS/MAX_ITEMS config plumbing, missing-metadata neutrality. +- **C3**: full ids present, age stamps, dont-flip rendering + pitfall + fallback, XML tag envelope, affordance line, empty on no items. +- **C4**: once-per-session dedup, REINJECT_TURNS re-eligibility, corrupt file + → empty, prune of stale files, turn counter monotonicity. +- **C5**: protocol conformance both backends (sqlite insert / agentcore + no-op), migration 0011 round-trip data preservation, source='contextual' + accepted, exposure-write failure does not block injection. +- **C6**: directive text includes sources and counts, diagnostics counters + increment on fire/inject/suppress paths. +- **E2E** (integration): contextual injection → exposure row → Stop directive + lists it → `apply_session_ratings` credits it → ranking reflects the bump. +- Conventions: pytest `asyncio_mode=auto`, tests mirror package dirs, clocks + injected, no inline INSERT SQL where seed helpers exist. + +## Out of scope + +- Automatic decay of never-rated memories (revisit with diagnostics data). +- Embedding-based relevance on the hot path. +- AgentCore-side exposure log. +- Promote-to-knowledge flows. diff --git a/tests/db/test_migration_0012_contextual_exposure.py b/tests/db/test_migration_0012_contextual_exposure.py new file mode 100644 index 0000000..76b0718 --- /dev/null +++ b/tests/db/test_migration_0012_contextual_exposure.py @@ -0,0 +1,164 @@ +"""Migration 0012: widen exposure source CHECK to include 'contextual'; +seed contextual diagnostics counters. + +NOTE: the design doc / plan drafted this as migration "0011", but +0011_trigram_fts.sql (PR #66) landed on main first and claimed that +version. This migration is 0012 instead; only the version number +changed, the SQL content matches the plan's 0011 spec verbatim. + +Data-preservation round-trip per project reflection 96936ffc. +""" +from __future__ import annotations + +import shutil +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from better_memory.db.connection import connect +from better_memory.db.schema import apply_migrations + +_MIGRATIONS_DIR = 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() + + +class TestExposureSourceCheck: + def test_contextual_source_accepted(self, conn): + conn.execute( + "INSERT INTO session_memory_exposure " + "(session_id, memory_kind, memory_id, exposed_at, source) " + "VALUES ('s1', 'reflection', 'r1', '2026-07-11T00:00:00+00:00', 'contextual')" + ) + row = conn.execute( + "SELECT source FROM session_memory_exposure WHERE session_id='s1'" + ).fetchone() + assert row["source"] == "contextual" + + def test_unknown_source_still_rejected(self, conn): + # Guard: the CHECK is widened, not dropped. + 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-07-11T00:00:00+00:00', 'bogus')" + ) + + def test_exposure_table_columns_preserved(self, conn): + cols = { + r["name"] for r in conn.execute( + "PRAGMA table_info(session_memory_exposure)" + ).fetchall() + } + assert cols == { + "session_id", "memory_kind", "memory_id", + "exposed_at", "source", "rated_at", "classification", + } + + def test_primary_key_preserved(self, conn): + pk_rows = [ + r for r in conn.execute( + "PRAGMA table_info(session_memory_exposure)" + ).fetchall() if r["pk"] > 0 + ] + pk_cols = [r["name"] for r in sorted(pk_rows, key=lambda r: r["pk"])] + assert pk_cols == [ + "session_id", "memory_kind", "memory_id", "exposed_at", + ] + + def test_indexes_recreated(self, conn): + 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 + + +class TestContextualDiagnostics: + def test_seeds_contextual_diagnostics_counters(self, conn): + metrics = { + r["metric"] + for r in conn.execute("SELECT metric FROM rating_diagnostics").fetchall() + } + assert { + "contextual_fired_userprompt", + "contextual_fired_pretool", + "contextual_injected", + "contextual_suppressed_floor", + "contextual_suppressed_dedup", + } <= metrics + + def test_contextual_counters_default_to_zero(self, conn): + rows = { + r["metric"]: r["value"] + for r in conn.execute("SELECT metric, value FROM rating_diagnostics").fetchall() + } + for metric in ( + "contextual_fired_userprompt", + "contextual_fired_pretool", + "contextual_injected", + "contextual_suppressed_floor", + "contextual_suppressed_dedup", + ): + assert rows[metric] == 0 + + +class TestExposureRowSurvivesMigration0012: + """Prove that rows present before 0012 survive the DROP+RENAME recreation.""" + + def test_existing_rows_preserved_after_table_recreation(self, tmp_memory_db: Path): + with tempfile.TemporaryDirectory() as td: + pre_dir = Path(td) / "pre" + pre_dir.mkdir() + for sql_file in sorted(_MIGRATIONS_DIR.glob("[0-9][0-9][0-9][0-9]_*.sql")): + prefix = int(sql_file.name.split("_", 1)[0]) + if prefix <= 11: + shutil.copy(sql_file, pre_dir / sql_file.name) + + c = connect(tmp_memory_db) + try: + apply_migrations(c, migrations_dir=pre_dir) + + # Insert a row with all optional columns populated, valid pre-0012. + c.execute( + "INSERT INTO session_memory_exposure " + "(session_id, memory_kind, memory_id, exposed_at, source, " + "rated_at, classification) " + "VALUES ('s0', 'semantic', 'm0', '2026-07-10T09:00:00+00:00', " + "'bootstrap', '2026-07-10T10:00:00+00:00', 'cited')" + ) + c.commit() + + m0012 = _MIGRATIONS_DIR / "0012_contextual_exposure.sql" + only_dir = Path(td) / "only12" + only_dir.mkdir() + shutil.copy(m0012, only_dir / m0012.name) + apply_migrations(c, migrations_dir=only_dir) + + row = c.execute( + "SELECT session_id, memory_kind, memory_id, exposed_at, " + "source, rated_at, classification " + "FROM session_memory_exposure WHERE session_id='s0'" + ).fetchone() + assert row is not None, "Row was lost during migration 0012 table recreation" + assert row["session_id"] == "s0" + assert row["memory_kind"] == "semantic" + assert row["memory_id"] == "m0" + assert row["exposed_at"] == "2026-07-10T09:00:00+00:00" + assert row["source"] == "bootstrap" + assert row["rated_at"] == "2026-07-10T10:00:00+00:00" + assert row["classification"] == "cited" + finally: + c.close() diff --git a/tests/hooks/test_contextual_inject.py b/tests/hooks/test_contextual_inject.py index 580fc5d..1bf28ba 100644 --- a/tests/hooks/test_contextual_inject.py +++ b/tests/hooks/test_contextual_inject.py @@ -4,11 +4,16 @@ import io import json import sys +from pathlib import Path import pytest +from better_memory.db.connection import connect +from better_memory.db.schema import apply_migrations from better_memory.hooks import contextual_inject as hook +_PROJECT = "ctx-inject-proj" + def _run(payload: dict, monkeypatch, capsys, mode="both"): monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", mode) @@ -20,6 +25,58 @@ def _run(payload: dict, monkeypatch, capsys, mode="both"): return json.loads(out) if out.strip() else {} +@pytest.fixture(autouse=True) +def bm_home(tmp_path: Path, monkeypatch) -> Path: + """Isolated BETTER_MEMORY_HOME with migrations applied and a fixed project. + + autouse so every test in this module runs against a tmp BETTER_MEMORY_HOME, + including the lower-level tests that don't reference the fixture by name + (e.g. test_userprompt_emits_envelope, test_mode_off_is_noop) -- otherwise + hook.main() falls back to the developer's real ~/.better-memory and writes + memory.db, diagnostics, and state/ files there. + """ + monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) + monkeypatch.setenv("BETTER_MEMORY_PROJECT", _PROJECT) + conn = connect(tmp_path / "memory.db") + try: + apply_migrations(conn) + finally: + conn.close() + return tmp_path + + +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", +) -> 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) + VALUES (?, ?, ?, 'general', ?, ?, ?, ?, '2026-01-01', + '2026-01-01', ?)""", + (rid, title, _PROJECT, polarity, use_cases, + json.dumps(hints or []), confidence, useful_count), + ) + conn.commit() + finally: + conn.close() + + +def _diag_value(home: Path, metric: str) -> int | None: + conn = connect(home / "memory.db") + try: + row = conn.execute( + "SELECT value FROM rating_diagnostics WHERE metric = ?", (metric,) + ).fetchone() + finally: + conn.close() + return row["value"] if row else None + + def test_userprompt_emits_envelope(monkeypatch, capsys): res = _run({"hook_event_name": "UserPromptSubmit", "prompt": "write the plan", "cwd": "."}, monkeypatch, capsys) @@ -53,3 +110,151 @@ def test_never_throws_on_garbage(monkeypatch, capsys): with pytest.raises(SystemExit) as e: hook.main() assert e.value.code == 0 + + +def test_injection_renders_project_memory_block(bm_home, monkeypatch, capsys): + _seed_reflection(bm_home, "refl-widget-deploy-1", title="widget deploy playbook") + res = _run( + {"hook_event_name": "UserPromptSubmit", "prompt": "deploy the widget service now", + "cwd": ".", "session_id": "sess-1"}, + monkeypatch, capsys, + ) + ctx = res["hookSpecificOutput"]["additionalContext"] + assert ctx.startswith("<project-memory") + assert "refl-widget-deploy-1" in ctx + + +def test_exposure_row_written_with_contextual_source(bm_home, monkeypatch, capsys): + _seed_reflection(bm_home, "refl-widget-deploy-2", title="widget deploy playbook") + _run( + {"hook_event_name": "UserPromptSubmit", "prompt": "deploy the widget service now", + "cwd": ".", "session_id": "sess-2"}, + monkeypatch, capsys, + ) + conn = connect(bm_home / "memory.db") + try: + row = conn.execute( + "SELECT source FROM session_memory_exposure " + "WHERE session_id = ? AND memory_id = ?", + ("sess-2", "refl-widget-deploy-2"), + ).fetchone() + finally: + conn.close() + assert row is not None + assert row["source"] == "contextual" + + +def test_second_run_suppressed_by_seen_store(bm_home, monkeypatch, capsys): + _seed_reflection(bm_home, "refl-widget-deploy-3", title="widget deploy playbook") + payload = { + "hook_event_name": "UserPromptSubmit", "prompt": "deploy the widget service now", + "cwd": ".", "session_id": "sess-3", + } + first = _run(payload, monkeypatch, capsys) + assert first["hookSpecificOutput"]["additionalContext"] != "" + + second = _run(payload, monkeypatch, capsys) + assert second["hookSpecificOutput"]["additionalContext"] == "" + assert _diag_value(bm_home, "contextual_suppressed_dedup") == 1 + + +def test_below_floor_injects_nothing(bm_home, monkeypatch, capsys): + _seed_reflection(bm_home, "refl-widget-only-4", title="widget playbook") + res = _run( + {"hook_event_name": "UserPromptSubmit", "prompt": "deploy the widget service now", + "cwd": ".", "session_id": "sess-4"}, + monkeypatch, capsys, + ) + assert res["hookSpecificOutput"]["additionalContext"] == "" + assert _diag_value(bm_home, "contextual_suppressed_floor") == 1 + + +def test_fired_counters(bm_home, monkeypatch, capsys): + _run( + {"hook_event_name": "UserPromptSubmit", "prompt": "hello world", + "cwd": ".", "session_id": "sess-5a"}, + monkeypatch, capsys, + ) + assert _diag_value(bm_home, "contextual_fired_userprompt") == 1 + + _run( + {"hook_event_name": "PreToolUse", "tool_name": "Skill", + "tool_input": {"skill": "writing-plans"}, "cwd": ".", "session_id": "sess-5b"}, + monkeypatch, capsys, + ) + assert _diag_value(bm_home, "contextual_fired_pretool") == 1 + + +def test_agentcore_mode_does_not_open_sqlite_connection(bm_home, monkeypatch, capsys): + """storage_backend=agentcore must never call connect() (better_memory.db.connection.connect). + + A true end-to-end agentcore hook test needs boto3/botocore stubs the hook-level + suite doesn't set up, so this narrows to: connect() is not invoked, and + build_backend is called with memory_conn=None, when storage_backend != sqlite. + """ + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", "both") + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + monkeypatch.setenv("BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID", "sem-1") + monkeypatch.setenv("BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID", "epi-1") + + connect_calls = [] + + class _FakeConn: + def close(self): + pass + + def _track_connect(*args, **kwargs): + connect_calls.append(args) + return _FakeConn() + + monkeypatch.setattr(hook, "connect", _track_connect) + + build_backend_calls = [] + + class _FakeBackend: + def retrieve(self, **kwargs): + return {} + + def semantic_list(self, **kwargs): + return [] + + def record_exposures(self, **kwargs): + pass + + def _fake_build_backend(**kwargs): + build_backend_calls.append(kwargs) + return _FakeBackend() + + monkeypatch.setattr(hook, "build_backend", _fake_build_backend) + + monkeypatch.setattr( + sys, "stdin", + io.StringIO(json.dumps({ + "hook_event_name": "UserPromptSubmit", "prompt": "hello world", "cwd": ".", + })), + ) + with pytest.raises(SystemExit) as e: + hook.main() + assert e.value.code == 0 + + assert connect_calls == [] + assert len(build_backend_calls) == 1 + assert build_backend_calls[0]["memory_conn"] is None + + +def test_exposure_write_failure_does_not_block_injection(bm_home, monkeypatch, capsys): + from better_memory.storage.sqlite import SqliteBackend + + def _raise(*args, **kwargs): + raise RuntimeError("exposure write boom") + + monkeypatch.setattr(SqliteBackend, "record_exposures", _raise) + + _seed_reflection(bm_home, "refl-widget-deploy-6", title="widget deploy playbook") + res = _run( + {"hook_event_name": "UserPromptSubmit", "prompt": "deploy the widget service now", + "cwd": ".", "session_id": "sess-6"}, + monkeypatch, capsys, + ) + ctx = res["hookSpecificOutput"]["additionalContext"] + assert "refl-widget-deploy-6" in ctx diff --git a/tests/hooks/test_session_close_rating_directive.py b/tests/hooks/test_session_close_rating_directive.py index 0933a7a..181d37c 100644 --- a/tests/hooks/test_session_close_rating_directive.py +++ b/tests/hooks/test_session_close_rating_directive.py @@ -23,7 +23,7 @@ def _run_hook(env: dict[str, str], stdin_data: str = "") -> subprocess.Completed ) -def _seed_unrated_exposure(db_path: Path, sid: str = "S1"): +def _seed_unrated_exposure(db_path: Path, sid: str = "S1", source: str = "bootstrap"): c = connect(db_path) apply_migrations(c) c.execute( @@ -36,9 +36,29 @@ def _seed_unrated_exposure(db_path: Path, sid: str = "S1"): c.execute( """INSERT INTO session_memory_exposure (session_id, memory_kind, memory_id, exposed_at, source) - VALUES (?, 'reflection', 'r1', '2026-05-11T11:00:00+00:00', - 'bootstrap')""", - (sid,), + VALUES (?, 'reflection', 'r1', '2026-05-11T11:00:00+00:00', ?)""", + (sid, source), + ) + c.commit() + c.close() + + +def _seed_semantic_exposure(db_path: Path, sid: str = "S1", source: str = "contextual"): + """Seed a semantic-memory unrated exposure. Assumes migrations already + applied (call after _seed_unrated_exposure, which applies them).""" + c = connect(db_path) + apply_migrations(c) + c.execute( + """INSERT INTO semantic_memories + (id, content, project, scope, created_at, updated_at) + VALUES ('s1', 'My Semantic Fact', 'p', 'project', + '2026-01-01', '2026-01-01')""" + ) + c.execute( + """INSERT INTO session_memory_exposure + (session_id, memory_kind, memory_id, exposed_at, source) + VALUES (?, 'semantic', 's1', '2026-05-11T12:00:00+00:00', ?)""", + (sid, source), ) c.commit() c.close() @@ -114,7 +134,7 @@ def test_multi_row_exposure_dedupes_in_directive( payload = json.loads(result.stdout) directive = payload["hookSpecificOutput"]["additionalContext"] # Memory id should appear exactly once in the reflection bucket. - assert directive.count("- r-dup:") == 1 + assert directive.count("- r-dup [") == 1 # Total rating count in the reason should be 1, not 2. assert "1 pending" in payload["reason"] @@ -163,3 +183,20 @@ def test_directive_lists_overlooked_class(self, tmp_path, tmp_memory_db): payload = json.loads(result.stdout) directive = payload["hookSpecificOutput"]["additionalContext"] assert "overlooked" in directive + + def test_directive_shows_source_labels_and_counts( + self, tmp_path, tmp_memory_db, + ): + _seed_unrated_exposure(tmp_memory_db, "S1", source="bootstrap") + _seed_semantic_exposure(tmp_memory_db, "S1", source="contextual") + env = { + "BETTER_MEMORY_HOME": str(tmp_memory_db.parent), + "CLAUDE_SESSION_ID": "S1", + } + result = _run_hook(env) + assert result.returncode == 0 + payload = json.loads(result.stdout) + directive = payload["hookSpecificOutput"]["additionalContext"] + assert "[bootstrap]" in directive + assert "[contextual]" in directive + assert "sources: bootstrap 1, contextual 1" in directive diff --git a/tests/integration/test_contextual_rating_e2e.py b/tests/integration/test_contextual_rating_e2e.py new file mode 100644 index 0000000..852d16d --- /dev/null +++ b/tests/integration/test_contextual_rating_e2e.py @@ -0,0 +1,123 @@ +"""End-to-end: contextual injection exposes a memory -> it shows up in +session exposures -> rating it 'cited' bumps useful_count -> a later +retrieve_relevant call ranks it (activation grew). + +Mirrors tests/integration/test_memory_rating_e2e.py's fixtures/seeding idiom: +a bare sqlite connection with migrations applied, direct SQL seeding, direct +SELECT assertions on the underlying tables. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from better_memory.db.connection import connect +from better_memory.db.schema import apply_migrations +from better_memory.services.relevant import retrieve_relevant +from better_memory.storage.sqlite import SqliteBackend + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def conn(tmp_memory_db: Path): + c = connect(tmp_memory_db) + apply_migrations(c) + try: + yield c + finally: + c.close() + + +# --------------------------------------------------------------------------- +# Seed helper +# --------------------------------------------------------------------------- + +def _seed_reflection(c, rid: str, title: str) -> None: + c.execute( + """INSERT INTO reflections + (id, title, project, phase, polarity, use_cases, hints, + confidence, created_at, updated_at) + VALUES (?, ?, 'proj', 'general', 'do', 'uc', '[]', 0.5, + '2026-01-01', '2026-01-01')""", + (rid, title), + ) + c.commit() + + +# --------------------------------------------------------------------------- +# The test +# --------------------------------------------------------------------------- + +@pytest.mark.integration +def test_contextual_injection_full_rating_loop(conn, monkeypatch, tmp_path): + """contextual exposure -> list_session_exposures includes it -> + apply_session_ratings('cited') bumps useful_count -> next retrieval + ranks it up.""" + monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False) + + # 1. Seed one reflection whose title shares >= 2 keywords with the + # query 'pytest windows redirect' (shares all three here). + _seed_reflection(conn, "r1", "pytest windows redirect capture") + + # 2. Build the sqlite backend. + backend = SqliteBackend( + memory_conn=conn, embedder=None, session_id="e2e-sess", project="proj", + ) + + # 3. Retrieve relevant memories for the query; the seeded reflection + # must be in the result. + items = retrieve_relevant(backend, query="pytest windows redirect", project="proj") + assert any(m.kind == "reflection" and m.id == "r1" for m in items), ( + f"expected r1 in retrieve_relevant results; got {items}" + ) + before_useful = next(m.useful_count for m in items if m.id == "r1") + assert before_useful == 0 + + # 4. Record the contextual exposure for everything just injected. + backend.record_exposures( + session_id="e2e-sess", + items=[(m.kind, m.id) for m in items], + source="contextual", + ) + + # 5. list_session_exposures must include r1 with source == 'contextual'. + exposures = backend.list_session_exposures(session_id="e2e-sess") + assert exposures["session_id"] == "e2e-sess" + r1_exposure = next( + e for e in exposures["exposures"] + if e["kind"] == "reflection" and e["id"] == "r1" + ) + assert r1_exposure["source"] == "contextual" + + # 6. Rate it 'cited' at session end. + result = backend.apply_session_ratings( + session_id="e2e-sess", + ratings=[{"kind": "reflection", "id": "r1", "class": "cited"}], + ) + assert result["applied"]["cited"] == 1 + assert all(v == 0 for v in result["skipped"].values()) + + # 7. useful_count on the underlying row is bumped. + useful_count = conn.execute( + "SELECT useful_count FROM reflections WHERE id='r1'" + ).fetchone()["useful_count"] + assert useful_count == 1, f"r1 useful_count should be 1; got {useful_count}" + + # The exposure row is now stamped as rated -- it drops out of the + # unrated session-exposure list. + exposures_after = backend.list_session_exposures(session_id="e2e-sess") + assert not any( + e["kind"] == "reflection" and e["id"] == "r1" + for e in exposures_after["exposures"] + ) + + # A subsequent retrieval still ranks r1 (now with a higher activation + # score thanks to the credited useful_count). + items_after = retrieve_relevant(backend, query="pytest windows redirect", project="proj") + r1_after = next(m for m in items_after if m.id == "r1") + assert r1_after.useful_count == 1 + r1_before_score = next(m.score for m in items if m.id == "r1") + assert r1_after.score > r1_before_score diff --git a/tests/services/test_context_seen.py b/tests/services/test_context_seen.py new file mode 100644 index 0000000..42823ed --- /dev/null +++ b/tests/services/test_context_seen.py @@ -0,0 +1,66 @@ +from datetime import UTC, datetime + +from better_memory.services.context_seen import SeenStore, prune_stale + + +def _store(tmp_path): + return SeenStore(tmp_path, "sess-1") + + +def test_first_exposure_passes_then_suppressed(tmp_path): + s = _store(tmp_path) + s.bump_turn() + ids = [("reflection", "r1"), ("semantic", "m1")] + assert s.filter_unseen(ids, reinject_turns=0) == ids + s.mark_seen(ids) + s2 = _store(tmp_path) # fresh instance = fresh hook process + s2.bump_turn() + assert s2.filter_unseen(ids, reinject_turns=0) == [] + + +def test_reinject_after_n_turns(tmp_path): + s = _store(tmp_path) + s.bump_turn() + s.mark_seen([("reflection", "r1")]) + for _ in range(3): + s2 = _store(tmp_path) + s2.bump_turn() + s3 = _store(tmp_path) + s3.bump_turn() # turn 5; injected at turn 1 + assert s3.filter_unseen([("reflection", "r1")], reinject_turns=3) == [("reflection", "r1")] + assert s3.filter_unseen([("reflection", "r1")], reinject_turns=10) == [] + + +def test_corrupt_file_treated_as_empty(tmp_path): + (tmp_path / "context_seen_sess-1.json").write_text("{not json", encoding="utf-8") + s = _store(tmp_path) + assert s.bump_turn() == 1 + assert s.filter_unseen([("reflection", "r1")], reinject_turns=0) == [("reflection", "r1")] + + +def test_sessions_are_isolated(tmp_path): + a = SeenStore(tmp_path, "sess-a") + a.bump_turn() + a.mark_seen([("reflection", "r1")]) + b = SeenStore(tmp_path, "sess-b") + b.bump_turn() + assert b.filter_unseen([("reflection", "r1")], reinject_turns=0) == [("reflection", "r1")] + + +def test_prune_stale_removes_old_files_only(tmp_path): + import os + old = tmp_path / "context_seen_old.json" + new = tmp_path / "context_seen_new.json" + old.write_text("{}", encoding="utf-8") + new.write_text("{}", encoding="utf-8") + ten_days_ago = datetime(2026, 7, 1, tzinfo=UTC).timestamp() + os.utime(old, (ten_days_ago, ten_days_ago)) + prune_stale(tmp_path, now=datetime(2026, 7, 11, tzinfo=UTC)) + assert not old.exists() + assert new.exists() + + +def test_missing_state_dir_never_raises(tmp_path): + s = SeenStore(tmp_path / "does-not-exist-yet", "sess-1") + assert s.bump_turn() == 1 # creates the dir + s.mark_seen([("reflection", "r1")]) diff --git a/tests/services/test_exposure_tracking.py b/tests/services/test_exposure_tracking.py index 02fed4a..c2531ea 100644 --- a/tests/services/test_exposure_tracking.py +++ b/tests/services/test_exposure_tracking.py @@ -216,6 +216,38 @@ def test_track_exposure_false_skips_write( assert rows == [] +class TestRecordExposuresPublicMethod: + def test_record_exposures_contextual_source(self, conn, fixed_clock): + from better_memory.services.session_bootstrap import SessionBootstrapService + + svc = SessionBootstrapService(conn, clock=fixed_clock) + svc.record_exposures( + session_id="s-ctx", + items=[("reflection", "r1"), ("semantic", "m1")], + source="contextual", + ) + rows = conn.execute( + "SELECT memory_kind, memory_id, source FROM session_memory_exposure " + "WHERE session_id='s-ctx' ORDER BY memory_kind" + ).fetchall() + assert [(r["memory_kind"], r["memory_id"], r["source"]) for r in rows] == [ + ("reflection", "r1", "contextual"), + ("semantic", "m1", "contextual"), + ] + + def test_record_exposures_empty_session_id_is_noop(self, conn, fixed_clock): + from better_memory.services.session_bootstrap import SessionBootstrapService + + svc = SessionBootstrapService(conn, clock=fixed_clock) + svc.record_exposures( + session_id="", items=[("reflection", "r1")], source="contextual", + ) + n = conn.execute( + "SELECT COUNT(*) AS n FROM session_memory_exposure" + ).fetchone()["n"] + assert n == 0 + + class TestSemanticListExposureWrite: def test_list_for_project_writes_exposure_rows( self, conn, fixed_clock, monkeypatch, diff --git a/tests/services/test_reflection_retrieve_fields.py b/tests/services/test_reflection_retrieve_fields.py new file mode 100644 index 0000000..34e7b0b --- /dev/null +++ b/tests/services/test_reflection_retrieve_fields.py @@ -0,0 +1,51 @@ +"""Verify retrieve_reflections carries times_misled + updated_at. + +Task 4's contextual relevance scorer consumes both fields to apply the +misled penalty and to weight by recency. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from better_memory.db.connection import connect +from better_memory.db.schema import apply_migrations +from better_memory.services.reflection import ReflectionSynthesisService + + +@pytest.fixture +def conn(tmp_memory_db: Path): + c = connect(tmp_memory_db) + apply_migrations(c) + try: + yield c + finally: + c.close() + + +def _seed_reflection(conn, rid, *, useful_count=0, confidence=0.5, + polarity="do", updated_at="2026-01-01", + times_overlooked=0, times_misled=0): + conn.execute( + """INSERT INTO reflections + (id, title, project, phase, polarity, use_cases, hints, + confidence, created_at, updated_at, useful_count, + times_overlooked, times_misled) + VALUES (?, ?, 'proj', 'general', ?, 'uc', '[]', ?, + '2026-01-01', ?, ?, ?, ?)""", + (rid, rid, polarity, confidence, updated_at, useful_count, + times_overlooked, times_misled), + ) + conn.commit() + + +def test_retrieve_reflections_includes_misled_and_updated_at(conn): + """Every bucket dict must carry times_misled and updated_at so the + contextual relevance scorer can apply the misled penalty and age.""" + _seed_reflection(conn, "r1") + svc = ReflectionSynthesisService(conn) + buckets = svc.retrieve_reflections(project="proj", track_exposure=False) + item = (buckets["do"] + buckets["dont"] + buckets["neutral"])[0] + assert item["times_misled"] == 0 + assert isinstance(item["updated_at"], str) and item["updated_at"] diff --git a/tests/services/test_relevant.py b/tests/services/test_relevant.py index 774d235..36766cb 100644 --- a/tests/services/test_relevant.py +++ b/tests/services/test_relevant.py @@ -1,60 +1,124 @@ -"""Tests for retrieve_relevant over a real sqlite StorageBackend.""" +"""Tests for retrieve_relevant scoring (hits x activation, min-hits floor).""" from __future__ import annotations -from pathlib import Path +from datetime import UTC, datetime import pytest -from better_memory.db.connection import connect -from better_memory.db.schema import apply_migrations from better_memory.services.relevant import RelevantMemory, retrieve_relevant -from better_memory.services.semantic import SemanticMemoryService -from better_memory.storage.sqlite import SqliteBackend - -@pytest.fixture -def backend(tmp_memory_db: Path): - conn = connect(tmp_memory_db) - apply_migrations(conn) - sem = SemanticMemoryService(conn) - sem.create(content="Always write the implementation plan with confidence scores", - project="proj", scope="project") - sem.create(content="Never ask the user to babysit a PR", project="other", scope="general") - sem.create(content="Prefer tea over coffee", project="proj", scope="project") - try: - yield SqliteBackend(memory_conn=conn, embedder=None, session_id=None, project="proj") - finally: - conn.close() - - -def test_filters_to_keyword_matches(backend): - out = retrieve_relevant(backend, query="let us write the plan", project="proj", limit=5) - texts = " ".join(m.summary.lower() for m in out) - assert "plan" in texts - assert "coffee" not in texts # irrelevant memory excluded +FIXED_NOW = datetime(2026, 7, 11, 12, 0, 0, tzinfo=UTC) -def test_includes_general_scope(backend): - out = retrieve_relevant(backend, query="babysit the PR", project="proj", limit=5) - assert any("babysit" in m.summary.lower() for m in out) # general-scope semantic matched +class _FakeBackend: + """Minimal stand-in for a StorageBackend: only the two methods + retrieve_relevant calls (retrieve, semantic_list) are implemented.""" + def __init__(self): + self.reflections: dict = {"do": [], "dont": [], "neutral": []} + self.semantic: list = [] + self.retrieve_error: Exception | None = None + self.semantic_error: Exception | None = None -def test_empty_on_no_match(backend): - assert retrieve_relevant(backend, query="xylophone zeppelin", project="proj", limit=5) == [] + def retrieve(self, *, project, track_exposure=False): + if self.retrieve_error is not None: + raise self.retrieve_error + return self.reflections + def semantic_list(self, *, project, track_exposure=False): + if self.semantic_error is not None: + raise self.semantic_error + return self.semantic -def test_empty_on_empty_query(backend): - assert retrieve_relevant(backend, query=" ", project="proj", limit=5) == [] - -def test_respects_limit(backend): - out = retrieve_relevant( - backend, query="plan confidence babysit pr user", project="proj", limit=1 - ) - assert len(out) == 1 - - -def test_returns_relevantmemory(backend): - out = retrieve_relevant(backend, query="plan", project="proj", limit=5) +@pytest.fixture +def fake_backend(): + return _FakeBackend() + + +def _reflection(id="r1", title="pytest windows redirect", hints=None, + useful_count=0, times_misled=0, confidence=0.8, + updated_at="2026-07-01T00:00:00+00:00", polarity="do"): + return { + "id": id, "title": title, "phase": "general", "use_cases": "", + "hints": hints or [], "confidence": confidence, "tech": None, + "evidence_count": 1, "useful_count": useful_count, + "times_misled": times_misled, "updated_at": updated_at, + "_polarity": polarity, # test helper only; buckets carry polarity + } + + +def test_backend_error_degrades_to_empty(fake_backend): + fake_backend.retrieve_error = RuntimeError("boom") + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", + now=lambda: FIXED_NOW) + assert out == [] + + +def test_empty_keyword_query_returns_empty(fake_backend): + fake_backend.reflections = {"do": [_reflection(title="alpha beta")], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query=" ", project="p", now=lambda: FIXED_NOW) + assert out == [] + + +def test_floor_rejects_single_hit_by_default(fake_backend): + fake_backend.reflections = {"do": [_reflection(title="pytest only-one")], + "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="pytest something unrelated", + project="p", now=lambda: FIXED_NOW) + assert out == [] # 1 distinct hit < min_hits=2 + + +def test_floor_admits_two_hits(fake_backend): + fake_backend.reflections = {"do": [_reflection(title="pytest windows redirect")], + "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="run pytest on windows", + project="p", now=lambda: FIXED_NOW) + assert [m.id for m in out] == ["r1"] + assert out[0].hits == 2 + assert out[0].age_days == 10 + assert out[0].kind == "reflection" + + +def test_title_hits_count_double_in_score(fake_backend): + title_match = _reflection(id="rt", title="alpha beta", updated_at="2026-07-01T00:00:00+00:00") + hint_match = _reflection(id="rh", title="zzz yyy", hints=["alpha beta"], + updated_at="2026-07-01T00:00:00+00:00") + fake_backend.reflections = {"do": [hint_match, title_match], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", now=lambda: FIXED_NOW) + assert out[0].id == "rt" # same hits, but title-weighted score wins + + +def test_misled_penalty_halves_score(fake_backend): + clean = _reflection(id="rc", title="alpha beta", useful_count=0, times_misled=0) + burned = _reflection(id="rb", title="alpha beta", useful_count=0, times_misled=3) + fake_backend.reflections = {"do": [burned, clean], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", now=lambda: FIXED_NOW) + assert out[0].id == "rc" + assert out[1].score < out[0].score + + +def test_max_items_cap(fake_backend): + fake_backend.reflections = {"do": [ + _reflection(id=f"r{i}", title="alpha beta") for i in range(6) + ], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", + max_items=3, now=lambda: FIXED_NOW) + assert len(out) == 3 + + +def test_missing_metadata_is_neutral(fake_backend): + r = _reflection(id="rm", title="alpha beta") + del r["times_misled"] # older backend shape + del r["updated_at"] + fake_backend.reflections = {"do": [r], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", now=lambda: FIXED_NOW) + assert out[0].age_days is None + assert out[0].score > 0 + + +def test_returns_relevantmemory(fake_backend): + fake_backend.reflections = {"do": [_reflection(title="alpha beta")], "dont": [], "neutral": []} + out = retrieve_relevant(fake_backend, query="alpha beta", project="p", now=lambda: FIXED_NOW) assert out and all(isinstance(m, RelevantMemory) for m in out) assert all(m.kind in ("reflection", "semantic") for m in out) diff --git a/tests/services/test_relevant_format.py b/tests/services/test_relevant_format.py index 9ec1826..d9bf7a0 100644 --- a/tests/services/test_relevant_format.py +++ b/tests/services/test_relevant_format.py @@ -1,24 +1,54 @@ """Tests for format_relevant.""" from __future__ import annotations +from dataclasses import replace + from better_memory.services.relevant import RelevantMemory, format_relevant +_BASE = RelevantMemory( + kind="reflection", id="a" * 32, text="Use junit-xml on windows", + polarity="do", confidence=0.9, useful_count=15, age_days=34, + hits=3, score=5.0, +) + + +def _mem(**kw): + return replace(_BASE, **kw) + -def test_empty_returns_empty_string(): +def test_empty_items_renders_empty(): assert format_relevant([]) == "" -def test_caps_items(): - items = [RelevantMemory("semantic", str(i), f"memory {i}", None, 1) for i in range(10)] - out = format_relevant(items, max_items=5) - assert out.count("•") == 5 +def test_block_structure_and_full_id(): + out = format_relevant([_mem()]) + assert out.startswith('<project-memory source="better-memory">') + assert out.rstrip().endswith("</project-memory>") + assert "a" * 32 in out # FULL id present + assert "conf 0.9" in out + assert "used 15x" in out + assert "34d old" in out + assert "memory_credit" in out # rating affordance line + assert "'cited'|'shaped'|'misled'" in out + + +def test_dont_polarity_rendered_as_corrective(): + out = format_relevant([_mem(polarity="dont", text="inline INSERT SQL in tests drifts")]) + assert "Known pitfall -- do this instead:" in out + + +def test_semantic_item_without_confidence(): + out = format_relevant([_mem(kind="semantic", polarity=None, confidence=None, + useful_count=0, text="repo uses uv run pytest")]) + assert "conf" not in out.split("\n")[2] # no conf tag on the semantic line + assert "semantic" in out -def test_includes_confidence_for_reflections(): - out = format_relevant([RelevantMemory("reflection", "r1", "do the thing", 0.9, 2)]) - assert "conf 0.90" in out +def test_missing_age_omitted(): + out = format_relevant([_mem(age_days=None)]) + assert "d old" not in out -def test_semantic_has_no_confidence_tag(): - out = format_relevant([RelevantMemory("semantic", "s1", "a fact", None, 1)]) - assert "· conf" not in out # the confidence tag, not the word "conflicts" in the header +def test_output_is_ascii(): + out = format_relevant([_mem()]) + out.encode("ascii") # raises if any non-ASCII slipped in diff --git a/tests/services/test_session_bootstrap.py b/tests/services/test_session_bootstrap.py index f33b8b1..6b93b03 100644 --- a/tests/services/test_session_bootstrap.py +++ b/tests/services/test_session_bootstrap.py @@ -2,9 +2,10 @@ from __future__ import annotations import json +import re import subprocess import uuid -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -16,29 +17,41 @@ _MIGRATIONS = Path(__file__).resolve().parents[2] / "better_memory" / "db" / "migrations" -def _seed_semantic(conn, *, content: str, project: str, scope: str) -> str: +def _seed_semantic( + conn, *, content: str, project: str, scope: str, updated_at: str | None = None +) -> str: mid = uuid.uuid4().hex now = datetime.now(UTC).isoformat() + ts = updated_at or now conn.execute( "INSERT INTO semantic_memories " "(id, content, project, scope, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?)", - (mid, content, project, scope, now, now), + (mid, content, project, scope, now, ts), ) conn.commit() return mid -def _seed_reflection(conn, *, project: str, polarity: str, scope: str = "project") -> str: +def _seed_reflection( + conn, + *, + project: str, + polarity: str, + scope: str = "project", + title: str = "t", + updated_at: str | None = None, +) -> str: rid = uuid.uuid4().hex now = datetime.now(UTC).isoformat() + ts = updated_at or now conn.execute( "INSERT INTO reflections " "(id, title, project, tech, phase, polarity, use_cases, hints, " " confidence, status, evidence_count, created_at, updated_at, scope) " - "VALUES (?, 't', ?, NULL, 'implementation', ?, 'uc', ?, 0.9, " + "VALUES (?, ?, ?, NULL, 'implementation', ?, 'uc', ?, 0.9, " " 'confirmed', 1, ?, ?, ?)", - (rid, project, polarity, json.dumps(["h"]), now, now, scope), + (rid, title, project, polarity, json.dumps(["h"]), now, ts, scope), ) conn.commit() return rid @@ -185,9 +198,10 @@ def test_render_includes_semantic_and_reflections_sections(conn, git_repo: Path) text = svc.bootstrap(source="startup", session_id="sess-r2", cwd=git_repo).additional_context - assert "Semantic memories (1 entries)" in text + assert "Semantic memories (1 shown in full)" in text assert "my-fact" in text - assert "Reflections — do (prior wins)" in text + assert "Reflections (shown in full)" in text + assert "[do]" in text def test_render_omits_empty_sections(conn, git_repo: Path) -> None: @@ -225,7 +239,7 @@ def test_render_multi_item_bucket(conn, git_repo: Path) -> None: assert f"_id: {rid1}_" in text assert f"_id: {rid2}_" in text # Header appears exactly once. - assert text.count("### Reflections — do (prior wins)") == 1 + assert text.count("### Reflections (shown in full)") == 1 def test_render_bucket_isolation(conn, git_repo: Path) -> None: @@ -234,10 +248,158 @@ def test_render_bucket_isolation(conn, git_repo: Path) -> None: svc = SessionBootstrapService(conn) text = svc.bootstrap(source="startup", session_id="t-iso", cwd=git_repo).additional_context - # Only the do bucket header appears; dont and neutral are empty so absent. - assert "Reflections — do (prior wins)" in text - assert "Reflections — dont (approaches to avoid)" not in text - assert "Reflections — neutral (context)" not in text + # Only the do polarity label appears; dont and neutral are absent. + assert "[do]" in text + assert "[dont" not in text + assert "[neutral]" not in text + + +# --------------------------------------------------------------------------- +# Task 9: bootstrap slimming — top-N full render + index + affordance. +# --------------------------------------------------------------------------- + + +def test_top_n_limits_full_renders(conn, git_repo: Path) -> None: + proj = git_repo.name + for i in range(8): + _seed_semantic(conn, content=f"sem-{i}", project=proj, scope="project") + + base = datetime(2026, 1, 1, tzinfo=UTC) + titles = [] + ids = [] + for i in range(8): + title = f"refl-title-{i}" + titles.append(title) + rid = _seed_reflection( + conn, + project=proj, + polarity="do", + scope="project", + title=title, + # Spaced updated_at so retrieve_reflections' updated_at DESC + # tiebreak makes ordering deterministic: highest i = newest = + # ranked first (full render); i=0 is oldest (index). + updated_at=(base + timedelta(minutes=i)).isoformat(), + ) + ids.append(rid) + + svc = SessionBootstrapService(conn, top_n=2) + text = svc.bootstrap( + source="startup", session_id="sess-topn", cwd=git_repo + ).additional_context + + full_sem_lines = re.findall(r"^- \[[0-9a-f]{32}\]", text, flags=re.MULTILINE) + assert len(full_sem_lines) == 2 + + assert text.count("_id: ") == 2 + + assert "### Index" in text + + # Oldest reflection (index 0) is guaranteed to land in the index — + # its title appears exactly once, and its id is never rendered. + assert text.count(titles[0]) == 1 + assert f"_id: {ids[0]}_" not in text + + +def test_general_semantic_always_full(conn, git_repo: Path) -> None: + proj = git_repo.name + for i in range(3): + _seed_semantic(conn, content=f"gen-{i}", project="anyproj", scope="general") + for i in range(3): + _seed_semantic(conn, content=f"proj-{i}", project=proj, scope="project") + + svc = SessionBootstrapService(conn, top_n=1) + text = svc.bootstrap( + source="startup", session_id="sess-gen", cwd=git_repo + ).additional_context + + full_sem_lines = re.findall(r"^- \[[0-9a-f]{32}\]", text, flags=re.MULTILINE) + # 3 general (always full) + 1 project-scope (top_n=1) = 4. + assert len(full_sem_lines) == 4 + assert "### Index" in text + + +def test_full_ids_never_truncated(conn, git_repo: Path) -> None: + proj = git_repo.name + mid = _seed_semantic(conn, content="fact", project=proj, scope="project") + + svc = SessionBootstrapService(conn, top_n=5) + text = svc.bootstrap( + source="startup", session_id="sess-fullid", cwd=git_repo + ).additional_context + + assert f"[{mid}]" in text + assert f"[{mid[:8]}]" not in text + + +def test_age_stamp_present(conn, git_repo: Path) -> None: + proj = git_repo.name + fixed_now = datetime(2026, 7, 11, tzinfo=UTC) + old_ts = (fixed_now - timedelta(days=10)).isoformat() + _seed_semantic(conn, content="aged-fact", project=proj, scope="project", updated_at=old_ts) + + svc = SessionBootstrapService(conn, clock=lambda: fixed_now, top_n=5) + text = svc.bootstrap( + source="startup", session_id="sess-age", cwd=git_repo + ).additional_context + + assert "(10d old)" in text + + +def test_affordance_footer_counts(conn, git_repo: Path) -> None: + proj = git_repo.name + for i in range(7): + _seed_semantic(conn, content=f"sem-{i}", project=proj, scope="project") + + svc = SessionBootstrapService(conn, top_n=1) + text = svc.bootstrap( + source="startup", session_id="sess-footer", cwd=git_repo + ).additional_context + + assert "6 more memories are indexed above" in text + assert "memory_retrieve" in text + + +def test_top_n_zero_is_legacy_full_dump(conn, git_repo: Path) -> None: + proj = git_repo.name + for i in range(5): + _seed_semantic(conn, content=f"sem-{i}", project=proj, scope="project") + for i in range(5): + _seed_reflection(conn, project=proj, polarity="do", scope="project", title=f"t-{i}") + + svc = SessionBootstrapService(conn, top_n=0) + text = svc.bootstrap( + source="startup", session_id="sess-legacy", cwd=git_repo + ).additional_context + + full_sem_lines = re.findall(r"^- \[[0-9a-f]{32}\]", text, flags=re.MULTILINE) + assert len(full_sem_lines) == 5 + assert text.count("_id: ") == 5 + assert "### Index" not in text + + +def test_exposures_only_for_full_renders(conn, git_repo: Path) -> None: + proj = git_repo.name + ids = [ + _seed_semantic(conn, content=f"sem-{i}", project=proj, scope="project") + for i in range(3) + ] + + svc = SessionBootstrapService(conn, top_n=1) + result = svc.bootstrap(source="startup", session_id="sess-exp", cwd=git_repo) + + rows = conn.execute( + "SELECT memory_id FROM session_memory_exposure WHERE session_id = ?", + ("sess-exp",), + ).fetchall() + exposed_ids = {r["memory_id"] for r in rows} + assert len(exposed_ids) == 1 + + full_id = next(iter(exposed_ids)) + assert f"[{full_id}]" in result.additional_context + for mid in ids: + if mid != full_id: + assert mid not in exposed_ids # --------------------------------------------------------------------------- diff --git a/tests/storage/test_agentcore_unit.py b/tests/storage/test_agentcore_unit.py index 2c9d64b..c4c1562 100644 --- a/tests/storage/test_agentcore_unit.py +++ b/tests/storage/test_agentcore_unit.py @@ -541,6 +541,19 @@ def test_list_session_exposures_returns_empty_envelope(backend) -> None: assert result == {"session_id": "test-session-xyz", "exposures": []} +def test_record_exposures_is_noop( + backend, mock_data_client, mock_control_client +) -> None: + """No exposure log in agentcore mode (see list_session_exposures). Must + not raise and must not call either boto client.""" + result = backend.record_exposures( + session_id="s", items=[("reflection", "r1")], source="contextual", + ) + assert result is None + assert mock_data_client.method_calls == [] + assert mock_control_client.method_calls == [] + + @pytest.mark.parametrize( "classification,counter_key", list(_RATING_TO_COUNTER.items()), @@ -920,6 +933,8 @@ def test_retrieve_malformed_json_content_falls_back_to_valid_shape(backend, mock assert refl["phase"] == "general" assert refl["useful_count"] == 2 assert refl["evidence_count"] == 3 # useful_count + missed_count + assert refl["times_misled"] == 0 + assert refl["updated_at"] is None or isinstance(refl["updated_at"], str) def test_retrieve_missing_content_key_falls_back_to_valid_shape(backend, mock_data_client) -> None: diff --git a/tests/storage/test_protocol.py b/tests/storage/test_protocol.py index a46aaf3..f477fa8 100644 --- a/tests/storage/test_protocol.py +++ b/tests/storage/test_protocol.py @@ -79,7 +79,7 @@ def test_protocol_declares_all_required_methods() -> None: "promote_reflection", "retire_reflection", # Session lifecycle "session_bootstrap", "list_session_exposures", - "apply_session_ratings", "credit_one", + "apply_session_ratings", "credit_one", "record_exposures", # Synthesis (sqlite-only — gated by supports_synthesis) "synthesize_next_get_context", "synthesize_next_apply", } @@ -93,7 +93,7 @@ def test_protocol_methods_are_keyword_only() -> None: for name in ( "observe", "semantic_observe", "open_background_episode", "close_active_episode", "apply_session_ratings", "credit_one", - "synthesize_next_apply", + "synthesize_next_apply", "record_exposures", ): method = getattr(StorageBackend, name) sig = inspect.signature(method) diff --git a/tests/test_config.py b/tests/test_config.py index 19bb003..fea5dfc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -416,3 +416,39 @@ def test_context_inject_mode_invalid(monkeypatch): monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", "bogus") with pytest.raises(ValueError): cfg_mod._resolve_context_inject_mode() + + +def test_injection_knobs_defaults(monkeypatch): + for var in ( + "BETTER_MEMORY_BOOTSTRAP_TOP_N", + "BETTER_MEMORY_CONTEXT_MIN_HITS", + "BETTER_MEMORY_CONTEXT_MAX_ITEMS", + "BETTER_MEMORY_CONTEXT_REINJECT_TURNS", + ): + monkeypatch.delenv(var, raising=False) + cfg = get_config() + assert cfg.bootstrap_top_n == 5 + assert cfg.context_min_hits == 2 + assert cfg.context_max_items == 3 + assert cfg.context_reinject_turns == 0 + + +def test_injection_knobs_env_override(monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_BOOTSTRAP_TOP_N", "0") + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_MIN_HITS", "1") + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_MAX_ITEMS", "5") + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_REINJECT_TURNS", "20") + cfg = get_config() + assert cfg.bootstrap_top_n == 0 + assert cfg.context_min_hits == 1 + assert cfg.context_max_items == 5 + assert cfg.context_reinject_turns == 20 + + +def test_injection_knobs_invalid_raises(monkeypatch): + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_MIN_HITS", "banana") + with pytest.raises(ValueError): + get_config() + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_MIN_HITS", "-1") + with pytest.raises(ValueError): + get_config() diff --git a/tests/ui/test_app.py b/tests/ui/test_app.py index ce1fc21..fcd8da0 100644 --- a/tests/ui/test_app.py +++ b/tests/ui/test_app.py @@ -132,7 +132,10 @@ def test_shutdown_schedules_exit_via_timer( class TestInactivityTimeout: def test_request_resets_last_activity(self, tmp_path: Path) -> None: - app = create_app(db_path=tmp_path / "memory.db") + # start_watchdog=False: a live watchdog thread from this app would + # see the fabricated _last_activity and os._exit(0) the whole pytest + # process one poll interval later. + app = create_app(start_watchdog=False, db_path=tmp_path / "memory.db") with app.test_client() as c: app.config["_last_activity"] = 0.0 # pretend ancient c.get("/episodes") @@ -140,7 +143,11 @@ def test_request_resets_last_activity(self, tmp_path: Path) -> None: assert _time.monotonic() - app.config["_last_activity"] < 0.1 def test_healthz_does_not_reset_last_activity(self, tmp_path: Path) -> None: - app = create_app(db_path=tmp_path / "memory.db") + # start_watchdog=False: this test leaves _last_activity at 0.0, which + # a real watchdog thread reads as "idle since boot" (time.monotonic() + # epoch) and os._exit(0)s the pytest process ~30s later — a silent + # exit-0 suite kill. + app = create_app(start_watchdog=False, db_path=tmp_path / "memory.db") with app.test_client() as c: app.config["_last_activity"] = 0.0 c.get("/healthz") @@ -148,7 +155,13 @@ def test_healthz_does_not_reset_last_activity(self, tmp_path: Path) -> None: assert app.config["_last_activity"] == 0.0 def test_check_idle_exits_when_over_threshold(self, tmp_path: Path) -> None: - app = create_app(inactivity_timeout=60.0, db_path=tmp_path / "memory.db") + # start_watchdog=False: _check_idle is driven synchronously below; a + # live watchdog would see the fabricated 120s idle and os._exit(0) + # the pytest process for real. + app = create_app( + inactivity_timeout=60.0, start_watchdog=False, + db_path=tmp_path / "memory.db", + ) app.config["_last_activity"] = _time.monotonic() - 120.0 # 2 min idle with patch("better_memory.ui.app.resolve_home", return_value=tmp_path), \ patch("better_memory.ui.app.os._exit") as mock_exit: @@ -156,7 +169,13 @@ def test_check_idle_exits_when_over_threshold(self, tmp_path: Path) -> None: mock_exit.assert_called_once_with(0) def test_check_idle_noop_when_under_threshold(self, tmp_path: Path) -> None: - app = create_app(inactivity_timeout=60.0, db_path=tmp_path / "memory.db") + # start_watchdog=False: with a 60s timeout, a live watchdog from this + # app would os._exit(0) the pytest process ~60s into the rest of the + # suite run. + app = create_app( + inactivity_timeout=60.0, start_watchdog=False, + db_path=tmp_path / "memory.db", + ) app.config["_last_activity"] = _time.monotonic() # just now with patch("better_memory.ui.app.os._exit") as mock_exit: app.config["_check_idle"]() diff --git a/tests/ui/test_diagnostics_ratings.py b/tests/ui/test_diagnostics_ratings.py index 3968007..6c68d41 100644 --- a/tests/ui/test_diagnostics_ratings.py +++ b/tests/ui/test_diagnostics_ratings.py @@ -114,7 +114,7 @@ def test_recent_ratings_panel_lists_rated_exposures( _seed_rated_exposure(conn, "S1", "reflection", "r1", "cited") monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_memory_db.parent)) - app = create_app() + app = create_app(start_watchdog=False) client = app.test_client() response = client.get("/diagnostics") assert response.status_code == 200 @@ -135,7 +135,7 @@ def test_session_id_missing_counter_displayed( conn.commit() monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_memory_db.parent)) - app = create_app() + app = create_app(start_watchdog=False) client = app.test_client() response = client.get("/diagnostics") body = response.data.decode("utf-8") @@ -165,7 +165,7 @@ def test_overlooked_total_displayed( conn.commit() monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_memory_db.parent)) - app = create_app() + app = create_app(start_watchdog=False) client = app.test_client() body = client.get("/diagnostics").data.decode("utf-8") assert "overlooked (total)" in body diff --git a/website/architecture.md b/website/architecture.md index ed78de2..34d9dc4 100644 --- a/website/architecture.md +++ b/website/architecture.md @@ -39,6 +39,7 @@ flowchart LR | Multi-machine sync | No | Yes (shared memory resources) | | Closure events | N/A | `CreateEvent(role=OTHER)` from Stop hook | | Episode tracking | Local `episodes` table | Internal to AgentCore (sessionId) | +| Exposure log (`record_exposures`) | Writes `session_memory_exposure` rows (sources `bootstrap` / `retrieve` / `contextual`) | No-op — no exposure log; rating flows through `memory.credit` only | See [Configuration](configuration.md) for env vars and [AgentCore setup](agentcore-setup.md) for the agentcore path. @@ -51,6 +52,23 @@ See [Configuration](configuration.md) for env vars and [AgentCore setup](agentco 3. **Reciprocal Rank Fusion (RRF)** combines the two ranked lists. 4. Results are filtered by the bucket's polarity and weighted by `reinforcement_score` (each `memory.record_use` shifts a memory's score up on success or down on failure). +## Injection strategies + +better-memory gets memory in front of Claude two ways: a slimmed-down dump at session start, and targeted mid-session injection keyed to what Claude is actually doing. + +**Bootstrap (SessionStart).** `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 (legacy behavior). + +**Contextual injection (UserPromptSubmit / PreToolUse).** The `contextual_inject` hook (`better_memory/hooks/contextual_inject.py`) scores the same curated memory set against the current prompt (UserPromptSubmit) or tool name + input (PreToolUse, matcher `Skill|Task|Write`) via `retrieve_relevant` (`better_memory/services/relevant.py`): + +- Keywords are extracted from the query (lowercased, tokenised, stopwords and <3-char tokens dropped). +- Each candidate's score is `(distinct keyword hits + title hits) × activation`, where activation grows with `useful_count` and confidence and is halved when a memory has misled more often than it has helped. +- Candidates below `BETTER_MEMORY_CONTEXT_MIN_HITS` distinct hits are dropped; survivors are ranked by score and capped to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`. +- A per-session `SeenStore` (`better_memory/services/context_seen.py`, JSON file at `<home>/state/context_seen_<session_id>.json`) 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 in mode `both` each matched tool call (`Skill`, `Task`, `Write`) is a separate turn too. +- Survivors render as a `<project-memory source="better-memory">` 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, 'cited'|'shaped'|'misled')` 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. + +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. + ## Embeddings backends better-memory supports two backends behind the @@ -96,16 +114,23 @@ captures whether memories actually shaped Claude's work: 1. **Exposure** — every reflection or semantic memory surfaced by `memory.retrieve` / `memory.semantic_retrieve` / the SessionStart - bootstrap is logged to `session_memory_exposure` with the active - `session_id`. + bootstrap / the `contextual_inject` hook is logged to + `session_memory_exposure` with the active `session_id` and a + `source` of `retrieve`, `bootstrap`, or `contextual` respectively + (sqlite mode only — see [Injection strategies](#injection-strategies) + for what `contextual_inject` scores and dedups before it exposes + anything). 2. **Mid-session credit** — `memory.credit(kind, id, class)` lets Claude credit a memory as `cited`, `shaped`, `misled`, or `overlooked` the moment it's used. Survives context compaction. 3. **End-of-session sweep** — the [`session_close`](https://github.com/emp3thy/better-memory/blob/main/better_memory/hooks/session_close.py) hook checks for unrated exposures. If any exist, it emits a `Stop` - block directive triggering the `rate-session-memories` skill, which - calls `memory.list_session_exposures` and submits + block directive triggering the `rate-session-memories` skill. The + directive lists each pending exposure with its source tag + (`[bootstrap]` / `[retrieve]` / `[contextual]`) and a leading + `sources: bootstrap N, contextual N, retrieve N` counts line, then + the skill calls `memory.list_session_exposures` and submits `memory.apply_session_ratings` with one class per id (`cited` / `shaped` / `ignored` / `misled` / `overlooked`). Only on the second Stop fire — after ratings land — does the hook drop the `session_end` diff --git a/website/configuration.md b/website/configuration.md index 686a658..8f63cdd 100644 --- a/website/configuration.md +++ b/website/configuration.md @@ -18,6 +18,10 @@ One environment variable roots the runtime filesystem layout. Everything else ha | `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_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 (legacy behavior). | +| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | Minimum number of distinct keyword hits a memory must clear against the current prompt / tool-input before `contextual_inject` will surface it. | +| `BETTER_MEMORY_CONTEXT_MAX_ITEMS` | `3` | Max number of memories `contextual_inject` injects per firing, after the min-hits floor 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 in mode `both` each matched tool call (`Skill`, `Task`, `Write`) counts as a separate turn too. | ## Project-name override @@ -49,6 +53,7 @@ Under `BETTER_MEMORY_HOME`: ├── knowledge.db # FTS5 index over knowledge-base/ ├── spool/ # hook payloads awaiting drain │ └── .quarantine/ # malformed payloads (not deleted; kept for debug) +├── state/ # per-session context_seen_<session_id>.json files (contextual_inject dedup) └── knowledge-base/ ├── standards/ # cross-project standards ├── languages/ # per-language conventions @@ -60,10 +65,11 @@ The two SQLite files are never shared between processes — the MCP server owns ## Hooks -Three Claude Code hooks ship with better-memory and read or write the filesystem layout above. They are installed automatically by `./scripts/setup.sh` (which calls `python -m better_memory.cli.install_hooks` to merge them idempotently into `~/.claude/settings.json`). The list below is reference material: +Four Claude Code hooks ship with better-memory and read or write the filesystem layout above. They are installed automatically by `./scripts/setup.sh` (which calls `python -m better_memory.cli.install_hooks` to merge them idempotently into `~/.claude/settings.json`). 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 (project-scoped and general-scope semantic memories plus the top distilled reflections in `do` / `dont` / `neutral` buckets, capped at 20 per bucket and ranked by usefulness then confidence) as `additionalContext` for Claude's first turn. Runs in-process against `memory.db`; failure-isolated: if bootstrap breaks, a fallback directive is injected and the failure is recorded in the `hook_errors` table. +- **`better_memory.hooks.session_bootstrap`** (SessionStart) — opens or reuses a background episode for the session and injects the project's curated context (project-scoped and general-scope semantic memories plus distilled reflections in `do` / `dont` / `neutral` buckets, retrieved up to 20 per bucket and ranked by usefulness then confidence) as `additionalContext` for Claude's first turn. Only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5; general-scope semantic memories are always shown in full) render in full; the rest collapse into a one-line index plus a `memory.retrieve` / `memory.retrieve_observations` affordance. Setting `BETTER_MEMORY_BOOTSTRAP_TOP_N=0` restores the legacy full-dump behavior. Runs in-process against `memory.db`; failure-isolated: if bootstrap breaks, a fallback directive is injected and the failure is recorded in the `hook_errors` table. - **`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/`. +- **`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). +- **`better_memory.hooks.contextual_inject`** (UserPromptSubmit + PreToolUse) — scores the curated memory set (semantic + reflections) against the current prompt or tool-input via keyword-hit x activation, drops candidates below `BETTER_MEMORY_CONTEXT_MIN_HITS`, caps survivors to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`, and injects them as a `<project-memory>` 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. See [`README.md`](https://github.com/emp3thy/better-memory/blob/main/README.md#manual-setup) for the exact `~/.claude/settings.json` registration JSON. diff --git a/website/mcp-tools.md b/website/mcp-tools.md index 030ca2e..d37bfbf 100644 --- a/website/mcp-tools.md +++ b/website/mcp-tools.md @@ -115,7 +115,7 @@ Spawn or reuse the management UI. Returns `{"url": "...", "reused": bool}`. ### `memory.session_bootstrap` -Open or reuse a session episode and inject project + general semantic memories and reflections as `additionalContext` markdown. Reflections are capped at 20 per polarity bucket (`do` / `dont` / `neutral`), ranked by usefulness then confidence. Mirrors what the SessionStart hook does; callable manually for recovery, testing, or post-`/clear` re-injection. +Open or reuse a session episode and inject project + general semantic memories and reflections as `additionalContext` markdown. Reflections are retrieved up to 20 per polarity bucket (`do` / `dont` / `neutral`), ranked by usefulness then confidence. Only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5; general-scope semantic memories always render in full) are shown in full — the rest collapse into a one-line index plus a retrieve affordance (`BETTER_MEMORY_BOOTSTRAP_TOP_N=0` restores the legacy full dump). Mirrors what the SessionStart hook does; callable manually for recovery, testing, or post-`/clear` re-injection. | Parameter | Type | Required | Notes | |---|---|---|---|