diff --git a/README.md b/README.md index f374753..48065fc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ It's a memory layer for Claude Code that (with the default sqlite backend) runs 1. **As it works**, the assistant records what it tried and how it turned out — a fix that worked, an approach that failed, a preference you stated. 2. **Between sessions**, those raw notes are distilled into short, durable lessons — signal kept, noise dropped. -3. **Next session**, lessons surface exactly when they become relevant: your standing rules inject at session start, and everything else arrives as you work — each prompt and the session's first tool call are scored against the memory store (text match + semantic similarity + track record), and only memories with real evidence of relevance inject. No firehose, no guessing. +3. **Next session**, lessons surface exactly when they become relevant: each typed prompt is scored against the memory store (distinct-keyword evidence plus track record), and every tool call is checked against each memory's triggers, so a pitfall about Bash heredocs arrives as the heredoc is about to run and a planning rule arrives when the planning skill is invoked. Session start carries only a one-line index. No firehose, no guessing. 4. **Every serve gets scored.** At session end the assistant rates each surfaced memory — and any non-trivial rating requires a one-line receipt (what the memory changed, or a quote). Hit rates drive the ranking: memories that keep helping rise, memories that keep getting ignored sink, and brand-new lessons get a reserved trial slot to earn their score. ## What you get @@ -84,7 +84,7 @@ uv run better-memory setup That single command: - Creates `~/.better-memory/{spool,state,install-backups,knowledge-base/{standards,languages,projects}}` and a default `settings.json` if none exists. - Registers the MCP server in `~/.claude.json` (creating the file if needed). -- Installs eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var into `~/.claude/settings.json`: `session_bootstrap` (SessionStart) opens/reuses the session's background episode and injects curated context — project-scoped and general-scope semantic memories plus distilled reflections (`do` / `dont` / `neutral` buckets) — as `additionalContext`, with only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5) rendered in full; `observer` (PostToolUse, `Write|Edit|Bash`) captures tool-call snapshots into the spool; `session_close` + `stop_sweep` (Stop) drive the end-of-session rating sweep and a reminder to record non-obvious observations; `contextual_inject` (UserPromptSubmit every prompt, and PreToolUse unscoped but latched to one firing per session) surfaces memories relevant to the current prompt/tool-input — see [Configuration](website/configuration.md) and [Architecture](website/architecture.md#injection-strategies) for how it scores, floors, and dedups candidates; `commit_checkpoint` (PreToolUse, gated to `Bash(git commit*)`) reminds Claude to record an observation before committing; `pre_compact` (PreCompact) reminds Claude to persist state before context compaction. +- Installs eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var into `~/.claude/settings.json`: `session_bootstrap` (SessionStart) opens/reuses the session's background episode and injects a one-line index of what the store holds as `additionalContext` (the legacy full dump is an opt-in via `BETTER_MEMORY_INJECT_MODE=legacy`); `observer` (PostToolUse, `Write|Edit|Bash`) captures tool-call snapshots into the spool; `session_close` + `stop_sweep` (Stop) drive the end-of-session rating sweep and a reminder to record non-obvious observations; `contextual_inject` (UserPromptSubmit every prompt, and PreToolUse every tool call) surfaces memories relevant to the current prompt through a distinct-keyword evidence gate, and memories whose triggers match the current tool call — see [Configuration](website/configuration.md) and [Architecture](website/architecture.md#injection-strategies) for how it scores, floors, and dedups candidates; `commit_checkpoint` (PreToolUse, gated to `Bash(git commit*)`) reminds Claude to record an observation before committing; `pre_compact` (PreCompact) reminds Claude to persist state before context compaction. - Splices the managed CLAUDE.md block (the retrieve/reinforce/synthesize/record protocol) into `~/.claude/CLAUDE.md` between `` / `` markers, leaving the rest of your CLAUDE.md untouched. - Symlinks (or, without Windows symlink privilege, directory-junctions) every Claude Code skill found in the repo's `.claude/skills/`, and prunes links whose repo skill was removed — see [Claude Code skills](#claude-code-skills) below. @@ -118,10 +118,11 @@ One env var roots the runtime filesystem layout: | `BETTER_MEMORY_STORAGE_BACKEND` | unset | `sqlite` or `agentcore`. Explicit override of the storage backend; when unset, `$BETTER_MEMORY_HOME/settings.json` (written by `better-memory agentcore init`) decides, falling back to `sqlite`. The env var always wins over settings.json. | | `BETTER_MEMORY_AUTO_PRUNE` | (unset = `false`) | When set to `1`, the auto-retention runner (which fires on `memory.retrieve`, throttled to once per 24h) ALSO hard-deletes archived observations older than 365 days. **Irreversible.** Default is archive-only (status flip, reversible). Opt in only if you actively want disk space reclaimed. | | `BETTER_MEMORY_PROJECT` | unset | Force the project name for all calls in this process. Highest-priority project-resolution signal — overrides both the `.better-memory` file and the git-derived name. Designed for subprocess scoping (e.g. ralph's executor sets it per-iteration so subagent observations land in the PBI's target_repo regardless of the worktree's cwd). Empty/whitespace-only values are treated as unset. | -| `BETTER_MEMORY_INJECT_MODE` | `legacy` (config default; `deferred` is the recommended and currently-deployed setting) | `deferred`: SessionStart injects only general-scope standing rules + a one-line index; everything else surfaces via the contextual channel as it becomes relevant. `legacy`: the original full bootstrap dump. Unknown values coerce to `legacy`. | -| `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt`, `pretool`, `both` (default), or `off`. PreToolUse fires once per session (any tool), latched. | +| `BETTER_MEMORY_INJECT_MODE` | `deferred` | `deferred` (default): SessionStart injects a one-line index only; memories surface via the typed-prompt and tool-call trigger channels as they become relevant. `legacy`: the original full bootstrap dump. Unknown values coerce to `deferred`. | +| `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt` (typed-prompt channel), `pretool` (tool-call trigger channel), `both` (default), or `off`. | | `BETTER_MEMORY_BOOTSTRAP_TOP_N` | `5` | Legacy-mode only: project-scoped items the SessionStart bootstrap renders in full; the rest collapse into a one-line index. `0` = full dump. | -| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | **Deprecated, unused** — superseded by the evidence gate (BM25 match, or a keyword-hit fallback when unavailable). Kept for back-compat only. | +| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | Distinct non-ubiquitous prompt tokens a memory must contain to be injected by the typed-prompt channel. | +| `BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS` | `8000` | Write content length above which the `write:large` trigger fires. | | `BETTER_MEMORY_CONTEXT_MAX_ITEMS` | `3` | Max memories the contextual-injection hook injects per firing. | | `BETTER_MEMORY_CONTEXT_REINJECT_TURNS` | `0` | Turns before a contextually-injected memory can be re-injected. `0` = never re-inject. A turn is one firing of the `contextual_inject` hook (each user prompt, plus each matched tool call in mode `both`), not one user prompt-response cycle. | @@ -298,7 +299,7 @@ See `docs/superpowers/specs/2026-04-06-better-memory-design.md` for the original Three shipped upgrades sit on top of that foundation (specs: `2026-07-23-retrieval-quality-design.md`, `2026-07-23-deferred-injection-design.md`): - **Wilson-score ranking.** Reflections and semantic memories rank by the lower bound of their observed hit rate — `(useful + overlooked) / (useful + overlooked + ignored)` — so a memory that helps 3 times out of 4 serves outranks one that helped 67 times out of 192. No raw-count rich-get-richer. One shortlist slot per bucket is reserved for memories with fewer than 3 ratings (the **exploration slot**), so new lessons get served, rated, and scored instead of starving; those serves are tagged (`via_exploration`) and excluded from headline usefulness metrics. -- **Deferred, evidence-gated injection.** SessionStart injects only your standing rules; each prompt (and the session's first tool call) is then scored against the store against an evidence gate — a BM25 text match for reflections, a keyword-hit fallback for semantic memories (which have no FTS substrate of their own) — with the Wilson prior fused in for ranking only. A memory injects solely on positive relevance evidence; popularity can never force an irrelevant injection. +- **Just-in-time, evidence-gated injection.** SessionStart injects an index line only. Each typed prompt is scored against the store through a distinct-hit gate (at least two distinct prompt tokens that are not ubiquitous across the pool must appear in a memory, the same rule for reflections and semantic memories) with the Wilson prior fused in for ranking only. Each tool call is checked against every memory's triggers (`tool:`, `skill:`, `bash:`, `path:`, `write:large`), so a lesson arrives at the moment it applies. Peer-agent messages and command output never count as prompts. The Diagnostics page reports the useful rate per channel so the effect is measurable. - **Evidence-anchored ratings.** Non-`ignored` ratings require a one-line receipt, enforced server-side and stored on the exposure row; the management UI shows each memory's rating-evidence history. Ratings are the fuel for everything above, so their variance is the system's noise floor — the receipts anchor them to observable events. ## Observation lifecycle diff --git a/better_memory/config.py b/better_memory/config.py index 7b1b660..d3aca39 100644 --- a/better_memory/config.py +++ b/better_memory/config.py @@ -224,13 +224,12 @@ class Config: context_max_items: int context_reinject_turns: int inject_mode: Literal["deferred", "legacy"] - # NOTE: context_min_hits is DEPRECATED. The evidence-gated scorer in - # services/relevant.py (BM25 qualifiers, with a keyword-hit fallback - # when no FTS substrate is available -- or the backend's - # relevance_ranks in agentcore mode) replaced the old pure - # keyword-hits floor; contextual_inject.py no longer reads this - # field. Kept for back-compat with any external - # BETTER_MEMORY_CONTEXT_MIN_HITS overrides that still resolve here. + # Size above which a Write tool call counts as "large" for the + # ``write:large`` trigger (services/triggers.py). + trigger_large_write_chars: int = 8000 + # context_min_hits is the distinct-hit floor of the prompt gate in + # services/relevant.py: a memory qualifies for contextual injection when + # at least this many distinct, non-ubiquitous query tokens appear in it. _DEFAULT_CONTEXT_INJECT_MODE = "both" @@ -250,9 +249,10 @@ def _resolve_context_inject_mode() -> Literal["userprompt", "pretool", "both", " def _resolve_inject_mode() -> Literal["deferred", "legacy"]: - raw = (os.environ.get("BETTER_MEMORY_INJECT_MODE") or "legacy").strip().lower() - # Fail-safe: anything unrecognised means today's behaviour. - return "deferred" if raw == "deferred" else "legacy" + raw = (os.environ.get("BETTER_MEMORY_INJECT_MODE") or "deferred").strip().lower() + # Deferred is the default (and what `better-memory setup` installs); + # legacy is an explicit opt-in to the pre-deferred session-start dump. + return "legacy" if raw == "legacy" else "deferred" def _read_settings_storage_backend(home: Path) -> str | None: @@ -348,4 +348,7 @@ def get_config() -> Config: context_max_items=_resolve_nonneg_int("BETTER_MEMORY_CONTEXT_MAX_ITEMS", 3), context_reinject_turns=_resolve_nonneg_int("BETTER_MEMORY_CONTEXT_REINJECT_TURNS", 0), inject_mode=_resolve_inject_mode(), + trigger_large_write_chars=_resolve_nonneg_int( + "BETTER_MEMORY_TRIGGER_LARGE_WRITE_CHARS", 8000 + ), ) diff --git a/better_memory/db/migrations/0019_memory_triggers.sql b/better_memory/db/migrations/0019_memory_triggers.sql new file mode 100644 index 0000000..4f8d326 --- /dev/null +++ b/better_memory/db/migrations/0019_memory_triggers.sql @@ -0,0 +1,14 @@ +-- Migration 0019: tool-call triggers on memories; trigger-channel diagnostics. +-- +-- ``triggers`` holds a JSON array of trigger strings (see +-- better_memory/services/triggers.py for the grammar) or NULL when a memory +-- has none. Rows without triggers are never served by the PreToolUse +-- trigger channel. See +-- docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md §2. + +ALTER TABLE reflections ADD COLUMN triggers TEXT; +ALTER TABLE semantic_memories ADD COLUMN triggers TEXT; + +INSERT INTO rating_diagnostics (metric, value) VALUES ('contextual_suppressed_nonhuman', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('trigger_fired', 0); +INSERT INTO rating_diagnostics (metric, value) VALUES ('trigger_injected', 0); diff --git a/better_memory/db/migrations/0020_exposure_source_trigger.sql b/better_memory/db/migrations/0020_exposure_source_trigger.sql new file mode 100644 index 0000000..e8251d6 --- /dev/null +++ b/better_memory/db/migrations/0020_exposure_source_trigger.sql @@ -0,0 +1,37 @@ +-- Migration 0020: widen session_memory_exposure.source CHECK to include +-- 'trigger' (the PreToolUse trigger channel; see +-- docs/superpowers/specs/2026-10-05-just-in-time-serving-design.md §1). +-- SQLite cannot ALTER a CHECK constraint, so the table is recreated with +-- every column added since migration 0012 (via_exploration, evidence, +-- display). No table holds a foreign key into session_memory_exposure. + +CREATE TABLE session_memory_exposure_new ( + session_id TEXT NOT NULL, + memory_kind TEXT NOT NULL CHECK(memory_kind IN ('reflection', 'semantic')), + memory_id TEXT NOT NULL, + exposed_at TEXT NOT NULL, + source TEXT NOT NULL CHECK(source IN ('bootstrap', 'retrieve', 'contextual', 'trigger')), + rated_at TEXT, + classification TEXT CHECK(classification IN + ('cited', 'shaped', 'ignored', 'misled', 'overlooked')), + via_exploration INTEGER NOT NULL DEFAULT 0, + evidence TEXT, + display TEXT, + PRIMARY KEY (session_id, memory_kind, memory_id, exposed_at) +); + +INSERT INTO session_memory_exposure_new + (session_id, memory_kind, memory_id, exposed_at, source, rated_at, + classification, via_exploration, evidence, display) +SELECT + session_id, memory_kind, memory_id, exposed_at, source, rated_at, + classification, via_exploration, evidence, display +FROM session_memory_exposure; + +DROP TABLE session_memory_exposure; +ALTER TABLE session_memory_exposure_new RENAME TO session_memory_exposure; + +CREATE INDEX idx_sme_session_unrated + ON session_memory_exposure(session_id) WHERE rated_at IS NULL; +CREATE INDEX idx_sme_memory + ON session_memory_exposure(memory_kind, memory_id); diff --git a/better_memory/hooks/contextual_inject.py b/better_memory/hooks/contextual_inject.py index 1fc04b4..93a70da 100644 --- a/better_memory/hooks/contextual_inject.py +++ b/better_memory/hooks/contextual_inject.py @@ -1,25 +1,27 @@ """UserPromptSubmit / PreToolUse hook: inject curated memories relevant to the -current prompt or tool-input. Gated by BETTER_MEMORY_CONTEXT_INJECT_MODE -(userprompt | pretool | both | off). Never raises; always exits 0. - -Candidates are scored via retrieve_relevant's evidence gate (BM25 -qualifiers, a keyword-hit fallback when no FTS substrate is available, or -the backend's relevance_ranks in agentcore mode — see -services/relevant.py) and capped at cfg.context_max_items. A per-session -SeenStore dedups injected memories across turns within a run -(cfg.context_reinject_turns controls re-injection after N turns). -Survivors are recorded as 'contextual' exposures (best-effort; -a write failure never blocks injection) and counted in rating_diagnostics for -observability (contextual_fired_userprompt/pretool, contextual_injected, -contextual_suppressed_floor, contextual_suppressed_dedup). - -PreToolUse is latched to one real firing per session -(SeenStore.try_claim_pretool_fired — an atomic O_CREAT|O_EXCL claim on a -sentinel file, race-safe across parallel hook processes): the installed -matcher is unscoped (all tools), so without the latch every tool call -would re-run the full retrieval path. Later PreToolUse events in the same -session short-circuit on the sentinel before any DB work. -UserPromptSubmit is unaffected by the latch. +current prompt (typed-prompt channel) or tool call (trigger channel). Gated +by BETTER_MEMORY_CONTEXT_INJECT_MODE (userprompt | pretool | both | off). +Never raises; always exits 0. + +UserPromptSubmit: non-human prompts (command output, system tags, peer +agent messages) are skipped outright; human prompts go through +retrieve_relevant's distinct-hit evidence gate (services/relevant.py) and +are capped at cfg.context_max_items. + +PreToolUse fires on EVERY tool call (no per-session latch any more) and is +trigger-only: memories carrying triggers (services/triggers.py grammar) +are matched against the tool name + input by triggered_memories; nothing +is keyword-matched against tool input. Measured cost of the full path is +within noise of the old latched short-circuit (184 ms vs 175 ms, of which +168 ms is interpreter start). Hits are logged with exposure source +'trigger' so the diagnostics panel can report the channel on its own. + +A per-session SeenStore dedups injected memories across both channels and +across turns (cfg.context_reinject_turns controls re-injection after N +turns). Exposure writes are best-effort -- a failure never blocks +injection. Counters in rating_diagnostics: contextual_fired_userprompt / +pretool, contextual_injected, contextual_suppressed_floor / dedup / +nonhuman, trigger_fired, trigger_injected. """ from __future__ import annotations @@ -34,17 +36,20 @@ from better_memory.db.connection import connect from better_memory.hooks._error_log import record_hook_error from better_memory.services.context_seen import SeenStore, prune_stale -from better_memory.services.relevant import format_relevant, retrieve_relevant +from better_memory.services.relevant import ( + format_relevant, + retrieve_relevant, + triggered_memories, +) from better_memory.storage import build_backend _MAX_STDIN_BYTES = 1_000_000 class _SkipInjection(Exception): - """Module-local sentinel: PreToolUse latch already fired this session. - - Caught explicitly (never via the outer BaseException guard) to leave - ``rendered = ""`` without treating the skip as an error. + """Module-local sentinel: this firing injects nothing by design (a + non-human prompt). Caught explicitly (never via the outer BaseException + guard) to leave ``rendered = ""`` without treating the skip as an error. """ @@ -63,6 +68,22 @@ def _bump_diagnostic(conn, cfg, metric: str) -> None: pass +#: Prompt prefixes that mark a UserPromptSubmit payload as NOT typed by a +#: human: command output and system tags (``<...>``) and peer-agent +#: messages. The hook payload carries no origin field, so the text is the +#: only signal. Measured: the hook fired 571 times across sessions holding +#: 261 typed prompts and 296 peer messages (spec Assumption A5). +NON_HUMAN_PREFIXES: tuple[str, ...] = ("<", "Another Claude session sent a message") + + +def is_human_prompt(text: str) -> bool: + """True for a non-empty prompt that does not start with a non-human prefix.""" + stripped = (text or "").strip() + if not stripped: + return False + return not stripped.startswith(NON_HUMAN_PREFIXES) + + def _enabled(event: str, mode: str) -> bool: if mode == "off": return False @@ -109,27 +130,26 @@ def main() -> None: state_dir = cfg.home / "state" prune_stale(state_dir, now=datetime.now(UTC)) seen = SeenStore(state_dir, session_id) - if event == "PreToolUse": - # Atomic O_CREAT|O_EXCL claim: if another parallel hook - # process already fired for this session, we return False - # and short-circuit before opening any DB connection. - if not seen.try_claim_pretool_fired(): - raise _SkipInjection() # module-local sentinel; caught below + if event == "UserPromptSubmit" and not is_human_prompt(query): + # Peer-agent messages, command output and system tags are + # not prompts: no injection, no exposure, no turn bump. + with closing(connect(cfg.memory_db)) as conn: + _bump_diagnostic(conn, cfg, "contextual_suppressed_nonhuman") + raise _SkipInjection() + if event == "PreToolUse" and cfg.storage_backend != "sqlite": + # The trigger channel needs trigger storage, which only the + # sqlite backend has. Return before any connection or + # backend build: on agentcore that would mean a boto3 import + # and two client builds on EVERY tool call for a channel + # that cannot fire. + raise _SkipInjection() seen.bump_turn() - # A real local connection is opened in BOTH modes now. Agentcore - # mode never stores memory CONTENT locally, but session- - # operational state (the exposure ledger) lives in the local - # memory.db regardless of backend — build_backend threads this - # conn through as the backend's exposure-ledger connection. The - # BM25 legs below still require the SQLITE-CONTENT - # substrate (reflection_fts), which agentcore has none of, so - # retrieve_relevant still gets conn=None for agentcore -- that - # parameter means "FTS index available", not "any local - # connection at all". retrieve_relevant itself detects - # agentcore (conn=None + supports_synthesis=False) and - # substitutes backend.relevance_ranks — a server-side semantic - # search — for the BM25 legs, so agentcore's evidence gate is - # no longer purely keyword-based despite conn=None here. + # A real local connection is opened in BOTH backend modes: the + # exposure ledger is session-operational state in the local + # memory.db regardless of where memory CONTENT lives. The + # ``conn`` passed to retrieve_relevant means "sqlite FTS + # substrate available", so agentcore gets None there and + # retrieve_relevant swaps in backend.relevance_ranks. with closing(connect(cfg.memory_db)) as conn: _bump_diagnostic( conn, cfg, @@ -142,17 +162,36 @@ def main() -> None: session_id=session_id or None, project=project, ) - items = retrieve_relevant( - backend, query=query, project=project, - conn=conn if cfg.storage_backend == "sqlite" else None, - max_items=cfg.context_max_items, - ) + if event == "PreToolUse": + _bump_diagnostic(conn, cfg, "trigger_fired") + tool_input = payload.get("tool_input") + items = triggered_memories( + backend, project=project, + tool_name=str(payload.get("tool_name") or ""), + tool_input=tool_input if isinstance(tool_input, dict) else {}, + large_write_chars=cfg.trigger_large_write_chars, + ) + source, injected_metric = "trigger", "trigger_injected" + else: + items = retrieve_relevant( + backend, query=query, project=project, + conn=conn if cfg.storage_backend == "sqlite" else None, + max_items=cfg.context_max_items, + min_hits=cfg.context_min_hits, + ) + source, injected_metric = "contextual", "contextual_injected" had_candidates = bool(items) pairs = [(m.kind, m.id) for m in items] unseen = set(seen.filter_unseen( pairs, reinject_turns=cfg.context_reinject_turns, )) items = [m for m in items if (m.kind, m.id) in unseen] + items = items[: cfg.context_max_items] + # Atomic per-memory claim: parallel hook processes (parallel + # tool calls) each read their own SeenStore snapshot, so + # without this both would serve the same memory. + won = set(seen.claim([(m.kind, m.id) for m in items])) + items = [m for m in items if (m.kind, m.id) in won] if items: rendered = format_relevant(items) survivors = [(m.kind, m.id) for m in items] @@ -161,7 +200,7 @@ def main() -> None: backend.record_exposures( session_id=session_id, items=exposure_items, - source="contextual", + source=source, ) except BaseException as exc: # noqa: BLE001 - never block injection try: @@ -169,10 +208,14 @@ def main() -> None: except BaseException: # noqa: BLE001 pass seen.mark_seen(survivors) - _bump_diagnostic(conn, cfg, "contextual_injected") + _bump_diagnostic(conn, cfg, injected_metric) elif had_candidates: _bump_diagnostic(conn, cfg, "contextual_suppressed_dedup") - else: + elif event == "UserPromptSubmit": + # The floor counter is the PROMPT gate's figure (it + # drives retuning of the distinct-hit floor); tool calls + # that match no trigger are measured by + # trigger_fired - trigger_injected instead. _bump_diagnostic(conn, cfg, "contextual_suppressed_floor") except _SkipInjection: rendered = "" diff --git a/better_memory/mcp/handlers/semantics.py b/better_memory/mcp/handlers/semantics.py index 8e18eab..b97ce4a 100644 --- a/better_memory/mcp/handlers/semantics.py +++ b/better_memory/mcp/handlers/semantics.py @@ -16,6 +16,17 @@ from better_memory.storage import StorageBackend +def _triggers_arg(args: dict[str, Any]) -> list[str] | None: + """``triggers`` from the tool args: None when absent/null, else a list of + str (validated downstream by services.triggers.validate_triggers).""" + raw = args.get("triggers") + if raw is None: + return None + if not isinstance(raw, list) or not all(isinstance(t, str) for t in raw): + raise ValueError("triggers must be a list of strings") + return raw + + class SemanticToolHandlers: """User-stated facts/preferences CRUD. @@ -47,11 +58,13 @@ async def semantic_observe(self, args: dict[str, Any]) -> list[TextContent]: # against MCP clients sending {"scope": null} — dict.get returns the # default only when the key is absent, not when its value is None. # Same fix as PR #25's BugBot finding on memory.observe. + triggers = _triggers_arg(args) if self._remote is not None: memory_id = self._remote.semantic_observe( content=args["content"], project=project, scope=args.get("scope") or "project", + triggers=triggers, ) return [ TextContent(type="text", text=json.dumps({"id": memory_id})) @@ -60,6 +73,7 @@ async def semantic_observe(self, args: dict[str, Any]) -> list[TextContent]: content=args["content"], project=project, scope=args.get("scope") or "project", + triggers=triggers, ) return [TextContent(type="text", text=json.dumps({"id": memory_id}))] @@ -90,6 +104,7 @@ async def semantic_retrieve(self, args: dict[str, Any]) -> list[TextContent]: "scope": record.scope, "created_at": None, "updated_at": None, + "triggers": list(getattr(record, "triggers", []) or []), } for record in self._remote.semantic_list( project=project, scope_filter=None @@ -105,18 +120,28 @@ async def semantic_retrieve(self, args: dict[str, Any]) -> list[TextContent]: "scope": m.scope, "created_at": m.created_at, "updated_at": m.updated_at, + "triggers": list(getattr(m, "triggers", []) or []), } for m in memories ] return [TextContent(type="text", text=json.dumps(payload))] async def semantic_update(self, args: dict[str, Any]) -> list[TextContent]: + """Edit content and/or triggers. At least one must be given.""" + content = args.get("content") + triggers = _triggers_arg(args) + if content is None and triggers is None: + raise ValueError("provide content and/or triggers") if self._remote is not None: - self._remote.semantic_update_text( - id=args["id"], content=args["content"] - ) + if content is not None: + self._remote.semantic_update_text(id=args["id"], content=content) + if triggers is not None: + self._remote.set_triggers(kind="semantic", id=args["id"], triggers=triggers) return [TextContent(type="text", text=json.dumps({"ok": True}))] - self._semantic.update_text(id=args["id"], content=args["content"]) + if content is not None: + self._semantic.update_text(id=args["id"], content=content) + if triggers is not None: + self._semantic.set_triggers(id=args["id"], triggers=triggers) return [TextContent(type="text", text=json.dumps({"ok": True}))] async def semantic_delete(self, args: dict[str, Any]) -> list[TextContent]: diff --git a/better_memory/mcp/tools.py b/better_memory/mcp/tools.py index 5c93301..fbd3b52 100644 --- a/better_memory/mcp/tools.py +++ b/better_memory/mcp/tools.py @@ -97,6 +97,15 @@ def tool_definitions( "'general' for cross-project workflow rules." ), }, + "triggers": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Tool-call triggers that serve this memory just in " + "time: tool:, skill:, " + "bash:, path:, write:large." + ), + }, }, }, ), @@ -124,15 +133,26 @@ def tool_definitions( Tool( name="memory.semantic_update", description=( - "Edit a semantic memory's content in place. Bumps updated_at." + "Edit a semantic memory's content and/or its tool-call " + "triggers in place. Give content, triggers, or both. " + "Bumps updated_at." ), inputSchema={ "type": "object", - "required": ["id", "content"], + "required": ["id"], "additionalProperties": False, "properties": { "id": {"type": "string"}, "content": {"type": "string"}, + "triggers": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Tool-call triggers that serve this memory just in " + "time: tool:, skill:, " + "bash:, path:, write:large." + ), + }, }, }, ), diff --git a/better_memory/services/context_seen.py b/better_memory/services/context_seen.py index c9f53f2..c7604f2 100644 --- a/better_memory/services/context_seen.py +++ b/better_memory/services/context_seen.py @@ -13,12 +13,10 @@ a partial JSON, and mutators re-read the on-disk snapshot before merging so a second process's writes are not silently clobbered. -The PreToolUse "one real firing per session" latch is a sibling sentinel -file ``context_seen_.pretool`` claimed atomically via -``os.open(..., O_CREAT|O_EXCL)``. Only :meth:`SeenStore.try_claim_pretool_fired` -is race-free; the older read-then-write pair (:meth:`pretool_fired` / -:meth:`mark_pretool_fired`) is retained for compatibility but callers on -the concurrent path must use the atomic claim. +Older versions kept a PreToolUse "one firing per session" latch as a +sibling ``context_seen_.pretool`` sentinel. The latch is gone +(PreToolUse is trigger-only and fires on every call); :func:`prune_stale` +still sweeps leftover sentinels from upgraded installs. """ from __future__ import annotations @@ -29,9 +27,11 @@ from datetime import datetime from pathlib import Path -# Matches the state JSON, the pretool sentinel, and stray `.tmp` siblings -# left behind by a process hard-killed between mkstemp and os.replace. -_FILE_RE = re.compile(r"^context_seen_.+\.(json|pretool|json\..+\.tmp)$") +# Matches the state JSON, per-memory claim sentinels, legacy pretool +# sentinels, and stray `.tmp` siblings left behind by a process hard-killed +# between mkstemp and os.replace. +_FILE_RE = re.compile(r"^context_seen_.+\.(json|claim|pretool|json\..+\.tmp)$") +_SAFE_KEY_RE = re.compile(r"[^A-Za-z0-9_.-]") _SAFE_SESSION_RE = re.compile(r"[^A-Za-z0-9_.-]") @@ -43,10 +43,48 @@ class SeenStore: def __init__(self, state_dir: Path, session_id: str) -> None: self._dir = state_dir safe = _SAFE_SESSION_RE.sub("_", session_id or "unknown") + self._safe = safe self._path = state_dir / f"context_seen_{safe}.json" - self._sentinel = state_dir / f"context_seen_{safe}.pretool" self._data = self._load() + def claim(self, ids: list[tuple[str, str]]) -> list[tuple[str, str]]: + """Atomically claim each (kind, id) for this session; return the ones + THIS caller won, in input order. + + Hook processes run in parallel (Claude issues parallel tool calls), + and :meth:`filter_unseen` reads a snapshot taken at process start, so + two processes can both see a memory as unseen. The claim is an + ``O_CREAT|O_EXCL`` sentinel per (session, kind, id, epoch): + ``context_seen_.-..claim`` where the epoch + is the turn the memory was last marked seen at in this process's + snapshot (0 if never). Parallel callers in the same re-inject window + share a snapshot and therefore a filename, so exactly one wins; once + ``mark_seen`` records a later turn and the + ``BETTER_MEMORY_CONTEXT_REINJECT_TURNS`` window re-admits the memory, + the epoch changes and a fresh claim is possible. Never raises: an + unwritable state dir claims nothing, so a failure degrades to "do + not serve" rather than "serve twice". + """ + won: list[tuple[str, str]] = [] + try: + self._dir.mkdir(parents=True, exist_ok=True) + except BaseException: # noqa: BLE001 - cannot claim -> serve nothing + return won + seen = self._data.get("seen") or {} + for kind, id_ in ids: + name = _SAFE_KEY_RE.sub("_", f"{kind}-{id_}") + epoch = int(seen.get(_key(kind, id_)) or 0) + sentinel = self._dir / f"context_seen_{self._safe}.{name}.{epoch}.claim" + try: + fd = os.open(str(sentinel), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + os.close(fd) + won.append((kind, id_)) + except FileExistsError: + continue + except BaseException: # noqa: BLE001 - best-effort; behave as "lost" + continue + return won + def _load(self) -> dict: try: raw = json.loads(self._path.read_text(encoding="utf-8")) @@ -125,41 +163,10 @@ def mark_seen(self, ids: list[tuple[str, str]]) -> None: self._data = {"turn": turn, "seen": merged_seen} self._save() - def try_claim_pretool_fired(self) -> bool: - """Atomic check-and-set on a sentinel file. - - Returns True iff this call created the sentinel (i.e. this process - is the first firing this session); False if the sentinel already - exists or the claim could not be established. Safe to race across - processes: ``O_CREAT|O_EXCL`` guarantees at most one caller sees - the True return per session. - """ - try: - self._dir.mkdir(parents=True, exist_ok=True) - fd = os.open( - str(self._sentinel), - os.O_CREAT | os.O_EXCL | os.O_WRONLY, - 0o644, - ) - os.close(fd) - return True - except FileExistsError: - return False - except BaseException: # noqa: BLE001 - best-effort; behave as "already claimed" - return False - - def pretool_fired(self) -> bool: - return self._sentinel.exists() - - def mark_pretool_fired(self) -> None: - # Retained for compatibility; the sentinel is idempotent so - # racing callers converge on the same on-disk state, though only - # try_claim_pretool_fired() distinguishes the winner. - self.try_claim_pretool_fired() - def prune_stale(state_dir: Path, *, now: datetime, max_age_days: int = 7) -> None: - """Delete context_seen state / pretool sentinels older than max_age_days. + """Delete context_seen state, claim sentinels and legacy pretool + sentinels older than max_age_days. Never raises. """ diff --git a/better_memory/services/reflection.py b/better_memory/services/reflection.py index 14e579b..87ebb5e 100644 --- a/better_memory/services/reflection.py +++ b/better_memory/services/reflection.py @@ -40,6 +40,7 @@ from better_memory._common import default_clock, env_session_id from better_memory.search.query import sanitize_fts5_query from better_memory.services.scoring import wilson_lower_bound +from better_memory.services.triggers import parse_triggers, validate_triggers # Dropped from relevance queries. These survive `sanitize_fts5_query` and the # >2-char filter, but appear in so many reflections that OR-matching on them @@ -1342,6 +1343,7 @@ def _bucket_item(self, r) -> dict: "times_overlooked": r["times_overlooked"], "times_ignored": r["times_ignored"], "updated_at": r["updated_at"], + "triggers": parse_triggers(r["triggers"]), } def retrieve_reflections( @@ -1409,7 +1411,7 @@ def retrieve_reflections( SELECT id, title, phase, polarity, use_cases, hints, confidence, tech, evidence_count, useful_count, times_misled, times_overlooked, times_ignored, - updated_at + updated_at, triggers FROM reflections WHERE {where} """, @@ -1637,6 +1639,23 @@ def update_text( ) self._conn.commit() + def set_triggers(self, *, reflection_id: str, triggers: list[str]) -> None: + """Replace a reflection's tool-call triggers (any status). + + Validates the grammar; an empty cleaned list stores NULL. Bumps + updated_at. Raises ValueError on bad grammar or missing id. + """ + cleaned = validate_triggers(triggers or []) + now = self._clock().isoformat() + cur = self._conn.execute( + "UPDATE reflections SET triggers = ?, updated_at = ? WHERE id = ?", + (json.dumps(cleaned) if cleaned else None, now, reflection_id), + ) + if cur.rowcount == 0: + self._conn.rollback() + raise ValueError(f"Reflection not found: {reflection_id}") + self._conn.commit() + def promote_to_general(self, *, reflection_id: str) -> None: """project → general; idempotent on already-general; raise on retired/superseded. diff --git a/better_memory/services/relevant.py b/better_memory/services/relevant.py index 1215dc6..45b6f14 100644 --- a/better_memory/services/relevant.py +++ b/better_memory/services/relevant.py @@ -1,32 +1,32 @@ """Relevance filter over the curated memory set (semantic + reflections). -Evidence-gated scorer, replacing the old pure-keyword hits-x-activation -model: a memory injects only when it has positive relevance EVIDENCE -- - -- BM25 match against ``reflection_fts`` (title / use_cases / hints), or -- (only when that leg is structurally unavailable -- no sqlite ``conn`` - for reflections; always, for semantics, which have no FTS substrate at - all) a keyword-hit floor as a degraded fallback. - -The Wilson lower-bound prior (see ``services.scoring``) never qualifies a -memory by itself -- it only RANKS among qualifiers via reciprocal rank -fusion (RRF), alongside the BM25 rank. Popularity forcing irrelevant -injections was the old failure mode (13% useful as bootstrap); the gate -exists specifically to close it. - -Fetches the small, already-ranked sets through the StorageBackend -abstraction (works on sqlite AND agentcore); the BM25 leg additionally -requires a raw sqlite ``conn``. Agentcore (``conn=None`` AND -``supports_synthesis=False``) replaces the BM25 leg wholesale with -``backend.relevance_ranks`` -- a server-side semantic-search rank map -- -and falls back to the keyword-hit floor ONLY when that lookup itself -FAILS (``relevance_ranks`` returns ``None`` -- an AWS error). A -successful lookup that genuinely finds nothing (``{}``) does NOT trigger -the fallback -- a legitimate negative result from the server-side gate -must not be overridden by keyword overlap. A sqlite backend called with -``conn=None`` keeps the pre-existing keyword-fallback behavior -unchanged, regardless of whether it also implements ``relevance_ranks`` -(it does, for protocol completeness only). +Evidence-gated scorer. On the sqlite backend ONE gate applies to both +memory kinds (design spec 2026-10-05-just-in-time-serving-design.md §1): + +- tokenise the prompt with ``extract_keywords``; +- drop any token that is *ubiquitous* in the candidate pool -- present in + more than ``DF_CAP`` of the pool's memories, counted only when the pool + has at least ``DF_MIN_POOL`` memories (a smaller pool keeps every token, + otherwise nothing could ever qualify); +- count distinct whole-word hits per memory; a memory qualifies when + ``hits >= min_hits`` (``cfg.context_min_hits``, default 2). + +The replay that motivated this (11 rated sessions) showed the old "any one +BM25 token" qualifier serving web-research pitfalls to a repo-exploration +prompt because both mentioned "Claude Code": a single shared token is not +evidence. Two distinct non-ubiquitous tokens measured 43% useful against +19% for the old gate. + +Ranking among qualifiers is reciprocal rank fusion of the Wilson prior +(``services.scoring``) with a relevance rank: distinct hits descending, +ties broken by BM25 rank over ``reflection_fts`` where that table exists. +The prior never qualifies a memory by itself. + +Agentcore (``conn=None`` AND ``supports_synthesis=False``) is unchanged: +the gate is membership in ``backend.relevance_ranks`` (server-side +semantic search), falling back to a keyword-hit floor ONLY when that +lookup itself FAILS (``None`` -- an AWS error); a genuinely empty ``{}`` is +a legitimate negative. """ from __future__ import annotations @@ -39,12 +39,21 @@ from better_memory.search.query import sanitize_fts5_query from better_memory.services.keywords import count_keyword_hits, extract_keywords from better_memory.services.scoring import wilson_lower_bound +from better_memory.services.triggers import match as match_trigger -#: Keyword-hit floor used only when the FTS leg is structurally -#: unavailable (no sqlite conn, for reflections; always, for semantics, -#: which have no FTS substrate at all). +#: Keyword-hit floor for the agentcore fallback (relevance_ranks failed). _FALLBACK_MIN_HITS = 2 +#: A token present in more than this fraction of the candidate pool is +#: ubiquitous and carries no evidence. Measured band: 50% on a pool of 10 +#: dropped "claude", "code", "user" and the like; see spec Assumptions A4. +DF_CAP = 0.5 + +#: The ubiquity filter only runs when the pool has at least this many +#: memories; below it every token in a 1- or 2-memory pool would exceed +#: DF_CAP and nothing could qualify. +DF_MIN_POOL = 4 + #: Reciprocal rank fusion constant, matching search/hybrid.py and #: ReflectionSynthesisService._fuse_by_relevance. _RRF_K = 60 @@ -61,6 +70,7 @@ class RelevantMemory: age_days: int | None hits: int score: float + reason: str | None = None # trigger string that fired (trigger channel only) def _age_days(iso_ts: str | None, now: datetime) -> int | None: @@ -93,6 +103,18 @@ def _bm25_qualifiers(conn: sqlite3.Connection | None, query: str) -> dict[str, i return {row[0]: i for i, row in enumerate(rows)} +def _ubiquitous(token_sets: list[set[str]]) -> set[str]: + """Tokens present in more than DF_CAP of the pool (pool >= DF_MIN_POOL).""" + n = len(token_sets) + if n < DF_MIN_POOL: + return set() + counts: dict[str, int] = {} + for toks in token_sets: + for t in toks: + counts[t] = counts.get(t, 0) + 1 + return {t for t, c in counts.items() if c > DF_CAP * n} + + def _wilson_for(useful: int, overlooked: int, ignored: int) -> float: positive = useful + overlooked n = useful + overlooked + ignored @@ -100,12 +122,12 @@ def _wilson_for(useful: int, overlooked: int, ignored: int) -> float: def _rrf_score(candidates: list[dict]) -> list[tuple[float, dict]]: - """RRF-fuse the Wilson prior with the BM25 rank already stashed - on each candidate dict (``bm_rank``, ``None`` if absent). + """RRF-fuse the Wilson prior with the relevance rank stashed on each + candidate dict (``rel_rank``, ``None`` if absent). - The prior rank is computed fresh here (desc by Wilson score) rather - than carried in, since it only makes sense relative to the other - qualifiers in this same candidate set. + Both ranks are computed relative to this candidate set: the prior rank + by Wilson score descending; the relevance rank by distinct hits + descending, then ``bm_rank`` ascending (None last), then id. """ order_by_wilson = sorted(range(len(candidates)), key=lambda i: -candidates[i]["wilson"]) prior_rank = {candidates[i]["id"]: rank for rank, i in enumerate(order_by_wilson)} @@ -113,13 +135,22 @@ def _rrf_score(candidates: list[dict]) -> list[tuple[float, dict]]: scored: list[tuple[float, dict]] = [] for c in candidates: present_ranks = [prior_rank[c["id"]]] - if c["bm_rank"] is not None: - present_ranks.append(c["bm_rank"]) + if c.get("rel_rank") is not None: + present_ranks.append(c["rel_rank"]) score = sum(1.0 / (_RRF_K + rank) for rank in present_ranks) scored.append((score, c)) return scored +def _assign_rel_ranks(candidates: list[dict]) -> None: + """Set ``rel_rank`` on each candidate: hits desc, bm_rank asc (None last), id.""" + def key(c: dict): + bm = c.get("bm_rank") + return (-c["hits"], bm if bm is not None else 10**9, c["id"]) + for rank, c in enumerate(sorted(candidates, key=key)): + c["rel_rank"] = rank + + def retrieve_relevant( backend: Any, *, @@ -129,26 +160,17 @@ def retrieve_relevant( max_items: int = 3, include_neutral: bool = False, now: Callable[[], datetime] | None = None, + min_hits: int = 2, ) -> list[RelevantMemory]: """Gate + rank curated memories (semantic + reflections) for ``query``. - A memory is returned only if it clears the evidence gate: a BM25 match - (reflections), or (only when that leg is structurally unavailable -- - no sqlite ``conn`` for reflections; always, for semantics, which have - no FTS leg at all) a keyword-hit fallback. Among qualifiers, ranking - is RRF over the Wilson prior plus the BM25 rank when present. - - Agentcore backends (``conn=None`` AND ``supports_synthesis=False``) - replace the BM25 leg with ``backend.relevance_ranks`` -- a - server-side semantic-search rank map fused into the same RRF -- and - fall back to the keyword-hit floor only when that lookup FAILS - (returns ``None`` -- an AWS error), never merely because it found - nothing (``{}`` is a legitimate negative result the gate must - respect). Sqlite backends are unaffected by this branch even when - called with ``conn=None``. - - Never raises -- any backend/leg failure degrades that leg to "absent" - rather than propagating. + Sqlite: a memory is returned only when at least ``min_hits`` distinct + non-ubiquitous query tokens appear in it (see module docstring). + Agentcore (``conn=None`` AND ``supports_synthesis=False``): membership + in ``backend.relevance_ranks``, with the keyword-hit floor only when + that lookup FAILS (``None``). Ranking is RRF of the Wilson prior and + the relevance rank. Never raises -- any backend/leg failure degrades + that leg to "absent". """ if not (query or "").strip(): return [] @@ -164,36 +186,17 @@ def retrieve_relevant( except Exception: # noqa: BLE001 - degrade to no semantic semantic = [] - bm = _bm25_qualifiers(conn, query) refl_bucket_order = ["do", "dont"] + (["neutral"] if include_neutral else []) - keywords = extract_keywords(query) # fallback evidence only - - fts_unavailable = conn is None - - # Agentcore evidence gate (design spec 2026-07-24-agentcore-parity- - # design.md §3): when the caller has no sqlite FTS substrate - # (conn=None) AND the backend is agentcore-flavored -- - # supports_synthesis=False, the existing Protocol capability flag that - # already distinguishes AgentCoreBackend from SqliteBackend everywhere - # else in this codebase -- the evidence gate becomes membership in a - # backend-computed relevance rank map (server-side RetrieveMemoryRecords - # semantic search) instead of the keyword-hit fallback. A conn=None - # SqliteBackend caller (supports_synthesis=True) is explicitly excluded - # here, so sqlite's own fallback semantics stay byte-for-byte unchanged - # regardless of whether SqliteBackend also implements relevance_ranks - # (it does, for protocol completeness -- see storage/sqlite.py -- but - # this function never calls it in that case). + agentcore_mode = ( conn is None and hasattr(backend, "relevance_ranks") and getattr(backend, "supports_synthesis", True) is False ) - # None vs {} from relevance_ranks is load-bearing (see - # StorageBackend.relevance_ranks's docstring): None means the - # backend-side lookup itself failed (AWS error on every namespace) -- - # THAT is the keyword-fallback trigger. {} means the lookup ran fine - # and genuinely found nothing, which must NOT re-qualify memories via - # keyword overlap -- the server-side gate's negative result stands. + # None vs {} from relevance_ranks is load-bearing: None means the lookup + # itself failed (AWS error) -- THAT is the keyword-fallback trigger. {} + # means it ran fine and found nothing, which must NOT re-qualify + # memories via keyword overlap. raw_rank_map: dict[tuple[str, str], int] | None = None if agentcore_mode: try: @@ -205,94 +208,83 @@ def retrieve_relevant( agentcore_kw_fallback = agentcore_mode and raw_rank_map is None rank_map: dict[tuple[str, str], int] = raw_rank_map or {} - refl_candidates: list[dict] = [] + # Flatten the pool once: (kind, id, display text, polarity, row/obj). + pool: list[dict] = [] for bucket in refl_bucket_order: for r in buckets.get(bucket, []) or []: - r_id = str(r.get("id")) title = str(r.get("title") or "") body = " ".join( [str(r.get("use_cases") or "")] + [str(h) for h in (r.get("hints") or [])] ) - text = f"{title} {body}" - kw_hits = count_keyword_hits(text, keywords) - - in_bm = r_id in bm - in_backend_rank = agentcore_mode and ("reflection", r_id) in rank_map - fallback_ok = ( - (agentcore_kw_fallback if agentcore_mode else fts_unavailable) - and kw_hits >= _FALLBACK_MIN_HITS - ) - if not (in_bm or in_backend_rank or fallback_ok): - continue - - refl_candidates.append({ - "id": r_id, "kind": "reflection", - "polarity": bucket if bucket in ("do", "dont") else None, + pool.append({ + "kind": "reflection", "id": str(r.get("id")), + "match_text": f"{title} {body}", "text": f"{title}: {body}".strip(": "), + "polarity": bucket if bucket in ("do", "dont") else None, "confidence": r.get("confidence"), "useful_count": int(r.get("useful_count") or 0), "age_days": _age_days(r.get("updated_at"), _now), - "hits": kw_hits if (in_bm or in_backend_rank or fallback_ok) else 0, - "bm_rank": ( - rank_map.get(("reflection", r_id)) - if agentcore_mode else bm.get(r_id) - ), - # storage.protocol.retrieve guarantees times_overlooked/ - # times_ignored on both backends (sqlite columns; agentcore - # copies its internal overlooked counter and hardcodes - # ignored=0 — see storage/agentcore.py::_parse_reflection_record). - # The .get(...) defaults below are defensive, not load-bearing. "wilson": _wilson_for( int(r.get("useful_count") or 0), int(r.get("times_overlooked") or 0), int(r.get("times_ignored") or 0), ), }) - - sem_candidates: list[dict] = [] - for s in semantic or []: - s_id = str(getattr(s, "id", "")) - content = getattr(s, "content", "") or "" - kw_hits = count_keyword_hits(content, keywords) - - in_backend_rank = agentcore_mode and ("semantic", s_id) in rank_map - # Semantics have no FTS/BM25 leg and no vec leg either, so the - # keyword-hit floor is their only evidence leg outside agentcore - # mode -- it is always "on" here (there is no other leg to gate - # it behind). In agentcore mode the backend rank map replaces it, - # so the fallback there fires only per the same - # agentcore_kw_fallback signal as reflections (relevance_ranks - # returned None == AWS error; a genuinely empty {} does NOT - # trigger it). - fallback_ok = ( - agentcore_kw_fallback if agentcore_mode else True - ) and kw_hits >= _FALLBACK_MIN_HITS - if not (in_backend_rank or fallback_ok): - continue - - sem_candidates.append({ - "id": s_id, "kind": "semantic", "polarity": None, - "text": content, + for s_ in semantic or []: + content = getattr(s_, "content", "") or "" + pool.append({ + "kind": "semantic", "id": str(getattr(s_, "id", "")), + "match_text": content, "text": content, "polarity": None, "confidence": None, - "useful_count": int(getattr(s, "useful_count", 0) or 0), - "age_days": _age_days(getattr(s, "updated_at", None), _now), - "hits": kw_hits if (in_backend_rank or fallback_ok) else 0, - "bm_rank": ( - rank_map.get(("semantic", s_id)) - if agentcore_mode else None - ), + "useful_count": int(getattr(s_, "useful_count", 0) or 0), + "age_days": _age_days(getattr(s_, "updated_at", None), _now), "wilson": _wilson_for( - int(getattr(s, "useful_count", 0) or 0), - int(getattr(s, "times_overlooked", 0) or 0), - int(getattr(s, "times_ignored", 0) or 0), + int(getattr(s_, "useful_count", 0) or 0), + int(getattr(s_, "times_overlooked", 0) or 0), + int(getattr(s_, "times_ignored", 0) or 0), ), }) + raw_keywords = extract_keywords(query) + if agentcore_mode: + keywords = raw_keywords # fallback evidence only + else: + ubiquitous = _ubiquitous([extract_keywords(c["match_text"]) for c in pool]) + keywords = raw_keywords - ubiquitous + bm = _bm25_qualifiers(conn, query) if not agentcore_mode else {} + + qualifiers: list[dict] = [] + for c in pool: + kw_hits = count_keyword_hits(c["match_text"], keywords) + key = (c["kind"], c["id"]) + if agentcore_mode: + in_backend_rank = key in rank_map + fallback_ok = agentcore_kw_fallback and kw_hits >= _FALLBACK_MIN_HITS + if not (in_backend_rank or fallback_ok): + continue + c["bm_rank"] = rank_map.get(key) + else: + if kw_hits < min_hits: + continue + c["bm_rank"] = bm.get(c["id"]) if c["kind"] == "reflection" else None + c["hits"] = kw_hits + qualifiers.append(c) + + refl_candidates = [c for c in qualifiers if c["kind"] == "reflection"] + sem_candidates = [c for c in qualifiers if c["kind"] == "semantic"] + if agentcore_mode: + # Preserve the backend's own ordering as the relevance rank. + for c in refl_candidates + sem_candidates: + c["rel_rank"] = c["bm_rank"] + else: + _assign_rel_ranks(refl_candidates) + _assign_rel_ranks(sem_candidates) + all_scored = _rrf_score(refl_candidates) + _rrf_score(sem_candidates) all_scored.sort(key=lambda t: (-t[0], t[1]["id"])) - out = [ + return [ RelevantMemory( kind=c["kind"], id=c["id"], text=c["text"], polarity=c["polarity"], confidence=c["confidence"], useful_count=c["useful_count"], @@ -300,6 +292,48 @@ def retrieve_relevant( ) for score, c in all_scored[:max_items] ] + + +def triggered_memories( + backend: Any, + *, + project: str, + tool_name: str, + tool_input: dict | None, + large_write_chars: int, + now: Callable[[], datetime] | None = None, +) -> list[RelevantMemory]: + """Trigger channel (PreToolUse): every memory whose triggers fire for + this tool call, best Wilson prior first. ``reason`` carries the trigger + string that fired. No keyword matching happens here -- a memory without + triggers is never tool-triggered. Never raises: a backend failure + yields [].""" + _now = (now or (lambda: datetime.now(UTC)))() + try: + candidates = backend.triggered_candidates(project=project) + except Exception: # noqa: BLE001 - degrade to no trigger candidates + return [] + out: list[RelevantMemory] = [] + for c in candidates or []: + reason = match_trigger( + list(c.get("triggers") or []), tool_name, tool_input, + large_write_chars=large_write_chars, + ) + if reason is None: + continue + wilson = _wilson_for( + int(c.get("useful_count") or 0), + int(c.get("times_overlooked") or 0), + int(c.get("times_ignored") or 0), + ) + out.append(RelevantMemory( + kind=str(c.get("kind")), id=str(c.get("id")), text=str(c.get("text") or ""), + polarity=c.get("polarity"), confidence=c.get("confidence"), + useful_count=int(c.get("useful_count") or 0), + age_days=_age_days(c.get("updated_at"), _now), + hits=0, score=wilson, reason=reason, + )) + out.sort(key=lambda m: (-m.score, m.id)) return out @@ -340,5 +374,7 @@ def format_relevant(items: list[RelevantMemory]) -> str: text = f"Known pitfall -- do this instead: {text}" lines.append(f"{i}. {_meta_tag(m)}") lines.append(f" {text}") + if m.reason: + lines.append(f" Triggered by: {m.reason}") lines.append(_BLOCK_FOOTER) return "\n".join(lines) diff --git a/better_memory/services/semantic.py b/better_memory/services/semantic.py index 178f5e9..0eb4449 100644 --- a/better_memory/services/semantic.py +++ b/better_memory/services/semantic.py @@ -9,14 +9,16 @@ from __future__ import annotations +import json import sqlite3 from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from uuid import uuid4 from better_memory._common import default_clock, env_session_id from better_memory.services.scoring import wilson_lower_bound +from better_memory.services.triggers import parse_triggers, validate_triggers @dataclass(frozen=True) @@ -37,6 +39,7 @@ class SemanticMemory: last_overlooked_at: str | None = None times_ignored: int = 0 last_ignored_at: str | None = None + triggers: list[str] = field(default_factory=list) _VALID_SCOPES = ("project", "general") @@ -59,7 +62,8 @@ def __init__( self._clock: Callable[[], datetime] = clock or default_clock def create( - self, *, content: str, project: str, scope: str = "project" + self, *, content: str, project: str, scope: str = "project", + triggers: list[str] | None = None, ) -> str: if scope not in _VALID_SCOPES: raise ValueError( @@ -67,15 +71,17 @@ def create( ) if not content.strip(): raise ValueError("content must not be empty") + cleaned = validate_triggers(triggers or []) memory_id = uuid4().hex now = self._clock().isoformat() self._conn.execute( """ INSERT INTO semantic_memories - (id, content, project, scope, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) + (id, content, project, scope, created_at, updated_at, triggers) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (memory_id, content, project, scope, now, now), + (memory_id, content, project, scope, now, now, + json.dumps(cleaned) if cleaned else None), ) self._conn.commit() return memory_id @@ -119,6 +125,25 @@ def set_scope(self, *, id: str, scope: str) -> None: raise ValueError(f"semantic memory not found: {id}") self._conn.commit() + def set_triggers(self, *, id: str, triggers: list[str]) -> None: + """Replace a semantic memory's tool-call triggers. + + Validates the grammar (``services.triggers.validate_triggers``); + an empty cleaned list stores NULL. Bumps updated_at. Raises + ValueError on bad grammar or missing id. + """ + cleaned = validate_triggers(triggers or []) + now = self._clock().isoformat() + cur = self._conn.execute( + "UPDATE semantic_memories SET triggers = ?, updated_at = ? " + "WHERE id = ?", + (json.dumps(cleaned) if cleaned else None, now, id), + ) + if cur.rowcount == 0: + self._conn.rollback() + raise ValueError(f"semantic memory not found: {id}") + self._conn.commit() + def create_from_observation( self, *, observation_id: str, scope: str = "project" ) -> str: @@ -194,7 +219,7 @@ def get(self, *, id: str) -> SemanticMemory | None: row = self._conn.execute( "SELECT id, content, project, scope, created_at, updated_at, " "useful_count, last_useful_at, times_misled, last_misled_at, " - "times_overlooked, last_overlooked_at, times_ignored, last_ignored_at " + "times_overlooked, last_overlooked_at, times_ignored, last_ignored_at, triggers " "FROM semantic_memories WHERE id = ?", (id,), ).fetchone() @@ -208,6 +233,7 @@ def get(self, *, id: str) -> SemanticMemory | None: times_overlooked=row["times_overlooked"] or 0, last_overlooked_at=row["last_overlooked_at"], times_ignored=row["times_ignored"] or 0, last_ignored_at=row["last_ignored_at"], + triggers=parse_triggers(row["triggers"]), ) def list_for_project( @@ -262,7 +288,7 @@ def list_for_project( "SELECT id, content, project, scope, created_at, updated_at, " "useful_count, last_useful_at, times_misled, last_misled_at, " "times_overlooked, last_overlooked_at, " - "times_ignored, last_ignored_at " + "times_ignored, last_ignored_at, triggers " "FROM semantic_memories " f"WHERE {' AND '.join(where_clauses)} " "ORDER BY created_at DESC" @@ -281,6 +307,7 @@ def list_for_project( last_overlooked_at=r["last_overlooked_at"], times_ignored=r["times_ignored"] or 0, last_ignored_at=r["last_ignored_at"], + triggers=parse_triggers(r["triggers"]), ) for r in rows ] diff --git a/better_memory/services/session_bootstrap.py b/better_memory/services/session_bootstrap.py index 2ed2c01..8e22b71 100644 --- a/better_memory/services/session_bootstrap.py +++ b/better_memory/services/session_bootstrap.py @@ -260,8 +260,12 @@ def bootstrap( } if get_config().inject_mode == "deferred": - general_only = [m for m in semantic if m.scope == "general"] - deferred_now = self._clock() + # Deferred renders NO memories at session start -- not even + # general-scope rules. Dumped at bootstrap they were rated + # ignored in 29 of 34 rated serves; they now arrive through the + # prompt gate and the tool-call trigger channel (spec + # 2026-10-05-just-in-time-serving-design.md §1). With nothing + # shown there is nothing to expose, so no ledger rows either. n_refl = sum(reflections_counts.values()) n_sem = semantic_count index_line = ( @@ -276,23 +280,10 @@ def bootstrap( action=action, episode_id=episode_id, ), + index_line, + "---", + _FOOTER, ] - deferred_sem_section, deferred_semantic_ids = _render_semantic_full( - general_only, deferred_now, - ) - if deferred_sem_section: - deferred_sections.append(deferred_sem_section) - deferred_sections.append(index_line) - deferred_sections.append("---") - deferred_sections.append(_FOOTER) - - self._record_exposure( - session_id=session_id, - reflection_ids=[], - semantic_ids=deferred_semantic_ids, - reflection_display={}, - semantic_display={m.id: m.content for m in general_only}, - ) return BootstrapResult( additional_context="\n\n".join(deferred_sections), diff --git a/better_memory/services/triggers.py b/better_memory/services/triggers.py new file mode 100644 index 0000000..a933da6 --- /dev/null +++ b/better_memory/services/triggers.py @@ -0,0 +1,123 @@ +"""Tool-call triggers: the grammar, its validator, and the matcher. + +A trigger is one string that names a tool-call condition under which a +memory should be served just in time (design spec +2026-10-05-just-in-time-serving-design.md §2). Five forms: + +- ``tool:`` -- the tool name equals ``Name`` (exact, case-sensitive). +- ``skill:`` -- the Skill tool is invoked with that skill; a trailing + ``*`` matches any skill with that prefix. +- ``bash:`` -- a Bash command contains ``text`` (case-insensitive). +- ``path:`` -- a Write / Edit / Read file path contains ``text`` + (case-insensitive, separators normalised to ``/``). +- ``write:large`` -- a Write whose content exceeds ``large_write_chars``. + +Pure functions, no I/O. The matcher never raises on odd input shapes: a +non-string operand is treated as absent, an unknown stored trigger string is +skipped. +""" +from __future__ import annotations + +import json +from typing import Any + +VALID_PREFIXES: tuple[str, ...] = ("tool:", "skill:", "bash:", "path:", "write:") + +_PATH_TOOLS: tuple[str, ...] = ("Write", "Edit", "Read") + +_GRAMMAR_HELP = "expected one of tool:, skill:, bash:, path:, write:large" + + +def validate_triggers(raw: list[str]) -> list[str]: + """Strip, drop empties, de-duplicate (order kept) and check the grammar. + + Raises ``ValueError`` naming the first offending string. + """ + out: list[str] = [] + for item in raw or []: + t = (item or "").strip() if isinstance(item, str) else "" + if not t: + continue + prefix, _, operand = t.partition(":") + prefix = prefix + ":" + bad = prefix not in VALID_PREFIXES or not operand + if prefix == "write:" and operand != "large": + bad = True + if bad: + raise ValueError(f"invalid trigger {t!r}: {_GRAMMAR_HELP}") + if t not in out: + out.append(t) + return out + + +def parse_triggers(raw: str | None) -> list[str]: + """Decode the JSON column. Anything that is not a JSON list of strings + (``None``, empty, malformed, wrong shape) decodes to ``[]``.""" + if not raw: + return [] + try: + data = json.loads(raw) + except (TypeError, ValueError): + return [] + if not isinstance(data, list) or not all(isinstance(x, str) for x in data): + return [] + return list(data) + + +def _str(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _fires(trigger: str, tool_name: str, tool_input: dict, large_write_chars: int) -> bool: + prefix, _, operand = trigger.partition(":") + if not operand: + return False + if prefix == "tool": + return tool_name == operand + if prefix == "skill": + if tool_name != "Skill": + return False + skill = _str(tool_input.get("skill")) + if skill is None: + return False + if operand.endswith("*"): + return skill.startswith(operand[:-1]) + return skill == operand + if prefix == "bash": + if tool_name != "Bash": + return False + command = _str(tool_input.get("command")) + return command is not None and operand.lower() in command.lower() + if prefix == "path": + if tool_name not in _PATH_TOOLS: + return False + path = _str(tool_input.get("file_path")) + if path is None: + return False + return operand.lower().replace("\\", "/") in path.lower().replace("\\", "/") + if prefix == "write": + if tool_name != "Write" or operand != "large": + return False + content = _str(tool_input.get("content")) + return content is not None and len(content) > large_write_chars + return False + + +def match( + triggers: list[str], + tool_name: str, + tool_input: dict | None, + *, + large_write_chars: int, +) -> str | None: + """Return the first trigger string that fires for this tool call, else None.""" + inp = tool_input if isinstance(tool_input, dict) else {} + for trigger in triggers or []: + if not isinstance(trigger, str): + continue + try: + if _fires(trigger, tool_name or "", inp, large_write_chars): + return trigger + except Exception: # noqa: BLE001 - a corrupt trigger must never break serving + continue + return None diff --git a/better_memory/setup/manifest.py b/better_memory/setup/manifest.py index 02805f3..18e0a2c 100644 --- a/better_memory/setup/manifest.py +++ b/better_memory/setup/manifest.py @@ -81,10 +81,9 @@ class HookSpec: "better_memory.hooks.contextual_inject", "UserPromptSubmit", None, False, True ), # Matcher is None (unscoped = all tools) rather than a tool-name - # alternation: the hook's per-session PreToolUse latch (SeenStore - # .pretool_fired/.mark_pretool_fired) makes an unscoped matcher cheap — - # only the first PreToolUse event per session does real work; every - # later one short-circuits on the state file before touching the DB. + # alternation: PreToolUse is the trigger channel and must see every + # tool call (memories declare which tools/skills/paths they fire on). + # Measured cost per call is interpreter start, not the trigger match. HookSpec( "better_memory.hooks.contextual_inject", "PreToolUse", None, False, True ), diff --git a/better_memory/storage/agentcore.py b/better_memory/storage/agentcore.py index c0362f5..df07118 100644 --- a/better_memory/storage/agentcore.py +++ b/better_memory/storage/agentcore.py @@ -176,6 +176,18 @@ def supports_reflection_review(self) -> bool: def supports_reflection_text_edit(self) -> bool: return False + @property + def supports_triggers(self) -> bool: + return False + + # ----- Triggers (unsupported: no trigger storage on AgentCore records) ----- + + def set_triggers(self, *, kind: str, id: str, triggers: list[str]) -> None: + raise NotImplementedError("triggers are not supported on the agentcore backend") + + def triggered_candidates(self, *, project: str) -> list[dict[str, Any]]: + return [] + # ----- Session-id per-operation re-resolution ----- def _require_session_id(self, operation: str) -> str: @@ -1419,10 +1431,15 @@ def semantic_observe( content: str, project: str | None = None, scope: str = "project", + triggers: list[str] | None = None, ) -> str: """Create a semantic memory record. Bypasses LLM extraction — the content is the preference text directly, written under the - userPreferenceMemoryStrategy so AWS applies its schema validation.""" + userPreferenceMemoryStrategy so AWS applies its schema validation. + ``triggers`` is rejected when non-empty: AgentCore records have no + trigger storage (``supports_triggers`` is False).""" + if triggers: + raise NotImplementedError("triggers are not supported on the agentcore backend") actor_id = resolve_actor_id(project or self._project) if scope == "general": namespace = resolve_namespace("general", "semantic") diff --git a/better_memory/storage/protocol.py b/better_memory/storage/protocol.py index 3dfdfba..6d3e0f6 100644 --- a/better_memory/storage/protocol.py +++ b/better_memory/storage/protocol.py @@ -98,6 +98,14 @@ def supports_reflection_review(self) -> bool: with NO pending_review state -- reflections are born active.""" ... + @property + def supports_triggers(self) -> bool: + """True when memories carry tool-call triggers that the PreToolUse + trigger channel can serve and the UI can edit (sqlite). False in + agentcore mode, where no trigger storage exists; the trigger + channel is then inactive and the UI hides the trigger editors.""" + ... + @property def supports_reflection_text_edit(self) -> bool: """True when a reflection's use_cases / hints text is user-editable in @@ -232,8 +240,12 @@ def semantic_observe( content: str, project: str | None = None, scope: str = "project", + triggers: list[str] | None = None, ) -> str: - """Create a semantic memory. Returns its id.""" + """Create a semantic memory. Returns its id. ``triggers`` is the + optional tool-call trigger list (services.triggers grammar); + backends without ``supports_triggers`` raise NotImplementedError + when it is non-empty.""" ... def semantic_list( @@ -269,6 +281,25 @@ def semantic_get(self, *, id: str) -> Any | None: ResourceNotFoundException as the None case.""" ... + # ----- Triggers ----- + + def set_triggers(self, *, kind: str, id: str, triggers: list[str]) -> None: + """Replace the tool-call triggers of a reflection or semantic memory. + ``kind`` is 'reflection' | 'semantic'. Raises ValueError on bad + grammar, unknown kind or missing id; NotImplementedError when the + backend does not support triggers.""" + ... + + def triggered_candidates(self, *, project: str) -> list[dict[str, Any]]: + """Memories visible to ``project`` (project rows plus general scope) + that carry at least one trigger, as dicts with keys ``kind``, ``id``, + ``text``, ``triggers``, ``polarity`` (None for semantic), + ``confidence`` (None for semantic), ``useful_count``, + ``times_overlooked``, ``times_ignored``, ``updated_at``. Retired and + superseded reflections are excluded; a row whose stored triggers do + not parse is skipped. Backends without trigger storage return [].""" + ... + # ----- Episodes ----- def open_background_episode( diff --git a/better_memory/storage/sqlite.py b/better_memory/storage/sqlite.py index 3925f14..1004ab4 100644 --- a/better_memory/storage/sqlite.py +++ b/better_memory/storage/sqlite.py @@ -19,6 +19,7 @@ from __future__ import annotations +import json import sqlite3 from collections.abc import Sequence from typing import Any @@ -32,6 +33,7 @@ ) from better_memory.services.semantic import SemanticMemoryService from better_memory.services.session_bootstrap import SessionBootstrapService +from better_memory.services.triggers import parse_triggers from better_memory.storage.protocol import Outcome, UseOutcome @@ -88,6 +90,10 @@ def supports_retention_runs(self) -> bool: def supports_reflection_review(self) -> bool: return True + @property + def supports_triggers(self) -> bool: + return True + @property def supports_reflection_text_edit(self) -> bool: return True @@ -177,11 +183,13 @@ def semantic_observe( content: str, project: str | None = None, scope: str = "project", + triggers: list[str] | None = None, ) -> str: return self._semantic.create( content=content, project=project or self._project, scope=scope, + triggers=triggers, ) def semantic_list( @@ -211,6 +219,68 @@ def semantic_delete(self, *, id: str) -> None: def semantic_get(self, *, id: str) -> Any | None: return self._semantic.get(id=id) + # ----- Triggers ----- + + def set_triggers(self, *, kind: str, id: str, triggers: list[str]) -> None: + if kind == "semantic": + self._semantic.set_triggers(id=id, triggers=triggers) + elif kind == "reflection": + self._reflection.set_triggers(reflection_id=id, triggers=triggers) + else: + raise ValueError( + f"unknown memory kind {kind!r}; expected 'reflection' or 'semantic'" + ) + + def triggered_candidates(self, *, project: str) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + refl_rows = self._conn.execute( + "SELECT id, title, use_cases, hints, polarity, confidence, useful_count, " + "times_overlooked, times_ignored, updated_at, triggers " + "FROM reflections " + "WHERE (project = ? OR scope = 'general') " + " AND status IN ('pending_review', 'confirmed') " + " AND triggers IS NOT NULL", + (project,), + ).fetchall() + for r in refl_rows: + triggers = parse_triggers(r["triggers"]) + if not triggers: + continue + try: + hints = json.loads(r["hints"] or "[]") + except (TypeError, ValueError): + hints = [] + body = " ".join([str(r["use_cases"] or "")] + [str(h) for h in hints]) + out.append({ + "kind": "reflection", "id": r["id"], + "text": f"{r['title']}: {body}".strip(": "), + "triggers": triggers, "polarity": r["polarity"], + "confidence": r["confidence"], + "useful_count": int(r["useful_count"] or 0), + "times_overlooked": int(r["times_overlooked"] or 0), + "times_ignored": int(r["times_ignored"] or 0), + "updated_at": r["updated_at"], + }) + sem_rows = self._conn.execute( + "SELECT id, content, useful_count, times_overlooked, times_ignored, " + "updated_at, triggers FROM semantic_memories " + "WHERE (project = ? OR scope = 'general') AND triggers IS NOT NULL", + (project,), + ).fetchall() + for r in sem_rows: + triggers = parse_triggers(r["triggers"]) + if not triggers: + continue + out.append({ + "kind": "semantic", "id": r["id"], "text": r["content"], + "triggers": triggers, "polarity": None, "confidence": None, + "useful_count": int(r["useful_count"] or 0), + "times_overlooked": int(r["times_overlooked"] or 0), + "times_ignored": int(r["times_ignored"] or 0), + "updated_at": r["updated_at"], + }) + return out + # ----- Episodes ----- def open_background_episode( diff --git a/better_memory/ui/app.py b/better_memory/ui/app.py index 053e7fe..1521999 100644 --- a/better_memory/ui/app.py +++ b/better_memory/ui/app.py @@ -37,6 +37,11 @@ def _reflection_drawer_detail(app: Flask, id: str) -> SimpleNamespace | None: return SimpleNamespace(reflection=SimpleNamespace(**row), sources=sources) +#: Sessions in the "recent" window of the useful-rate panel: the design's +#: measurement window for the 33% target (spec §3). +_RECENT_SESSIONS_WINDOW = 15 + + def create_app( *, inactivity_timeout: float = 1800.0, @@ -75,6 +80,17 @@ def create_app( project=project_name(), ) + def _triggers_field(flask_app) -> list[str] | None: + """The posted ``triggers`` textarea as a list (one per line), or + None when the field is absent or the backend has no trigger + storage -- a posted field is then ignored, never an error.""" + if not getattr(flask_app.extensions["backend"], "supports_triggers", False): + return None + raw = request.form.get("triggers") + if raw is None: + return None + return [line.strip() for line in raw.splitlines() if line.strip()] + @app.context_processor def _inject_caps() -> dict[str, object]: b = app.extensions["backend"] @@ -85,6 +101,7 @@ def _inject_caps() -> dict[str, object]: "supports_retention_runs": b.supports_retention_runs, "supports_reflection_review": b.supports_reflection_review, "supports_reflection_text_edit": b.supports_reflection_text_edit, + "supports_triggers": bool(getattr(b, "supports_triggers", False)), }} @app.teardown_appcontext @@ -473,6 +490,18 @@ def reflection_edit_save(id: str) -> tuple[str, int, dict[str, str]]: f"

{escape(str(exc))}

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

{escape(str(exc))}

" + "
" + ), 400, {} detail = queries.reflection_detail(conn, reflection_id=id) rating_evidence = queries.fetch_rating_evidence( conn, "reflection", id @@ -583,8 +612,13 @@ def semantic_drawer(id: str): @app.post("/semantic//update") def semantic_update(id: str) -> tuple[str, int, dict[str, str]]: content = request.form.get("content", "").strip() + triggers_field = _triggers_field(app) try: app.extensions["backend"].semantic_update_text(id=id, content=content) + if triggers_field is not None: + app.extensions["backend"].set_triggers( + kind="semantic", id=id, triggers=triggers_field, + ) except (ValueError, RuntimeError) as exc: return ( f'
{escape(str(exc))}
', @@ -709,6 +743,11 @@ def diagnostics() -> str: recent_ratings=recent_ratings, rating_diagnostics=rating_diagnostics, overlooked_total=overlooked_total, + useful_all=queries.useful_rate_by_channel(conn, last_n_sessions=None), + useful_recent=queries.useful_rate_by_channel( + conn, last_n_sessions=_RECENT_SESSIONS_WINDOW, + ), + recent_window=_RECENT_SESSIONS_WINDOW, ) @app.get("/diagnostics/panel/hook-errors") diff --git a/better_memory/ui/queries.py b/better_memory/ui/queries.py index f51a354..2cda77b 100644 --- a/better_memory/ui/queries.py +++ b/better_memory/ui/queries.py @@ -7,9 +7,10 @@ from __future__ import annotations import sqlite3 -from dataclasses import dataclass +from dataclasses import dataclass, field from better_memory.services.episode import Episode, row_to_episode +from better_memory.services.triggers import parse_triggers @dataclass(frozen=True) @@ -331,6 +332,7 @@ class ReflectionFull: last_misled_at: str | None = None times_overlooked: int = 0 last_overlooked_at: str | None = None + triggers: list[str] = field(default_factory=list) @dataclass(frozen=True) @@ -392,7 +394,7 @@ def reflection_row( "confidence, status, use_cases, hints, evidence_count, scope, " "created_at, updated_at, " "useful_count, last_useful_at, times_misled, last_misled_at, " - "times_overlooked, last_overlooked_at " + "times_overlooked, last_overlooked_at, triggers " "FROM reflections WHERE id = ?", (reflection_id,), ).fetchone() @@ -419,6 +421,7 @@ def reflection_row( last_misled_at=r_row["last_misled_at"], times_overlooked=r_row["times_overlooked"] or 0, last_overlooked_at=r_row["last_overlooked_at"], + triggers=parse_triggers(r_row["triggers"]), ) @@ -873,3 +876,81 @@ def retention_runs_list_for_ui( ) for r in rows ] + + +def useful_rate_by_channel( + conn: sqlite3.Connection, *, last_n_sessions: int | None +) -> dict: + """Useful rate per exposure source over rated exposures. + + ``useful = cited + shaped``; ``rated = useful + ignored + misled`` (the + spec's denominator -- ``overlooked`` ratings are reported in their own + column but excluded from the rate); ``rate = useful / rated`` (0.0 when + rated is 0). ``last_n_sessions=None`` is all time; otherwise only the N most + recently rated sessions (by ``MAX(rated_at)`` per session) count. + ``useful_per_session`` (useful ratings / rated sessions) is the guard + against reaching a ratio by starving the channels (design spec + 2026-10-05-just-in-time-serving-design.md §3). + + Returns ``{"rows": [...], "total": {...}, "rated_sessions": int, + "useful_per_session": float}``; each row has ``source``, ``rated``, + ``useful``, ``ignored``, ``misled``, ``overlooked``, ``rate``. + """ + session_filter = "" + params: list[object] = [] + if last_n_sessions is not None: + session_filter = ( + "AND session_id IN (" + " SELECT session_id FROM session_memory_exposure " + " WHERE rated_at IS NOT NULL " + " GROUP BY session_id ORDER BY MAX(rated_at) DESC LIMIT ?)" + ) + params.append(int(last_n_sessions)) + rows = conn.execute( + f""" + SELECT source, + SUM(classification IN ('cited', 'shaped')) AS useful, + SUM(classification = 'ignored') AS ignored, + SUM(classification = 'misled') AS misled, + SUM(classification = 'overlooked') AS overlooked + FROM session_memory_exposure + WHERE rated_at IS NOT NULL {session_filter} + GROUP BY source + ORDER BY source + """, + params, + ).fetchall() + sessions = conn.execute( + f""" + SELECT COUNT(DISTINCT session_id) FROM session_memory_exposure + WHERE rated_at IS NOT NULL {session_filter} + """, + params, + ).fetchone()[0] + + def _row(source: str, useful: int, ignored: int, misled: int, overlooked: int) -> dict: + rated = useful + ignored + misled + return { + "source": source, "rated": rated, "useful": useful, + "ignored": ignored, "misled": misled, "overlooked": overlooked, + "rate": (useful / rated) if rated else 0.0, + } + + out_rows = [ + _row(r["source"], r["useful"] or 0, r["ignored"] or 0, r["misled"] or 0, + r["overlooked"] or 0) + for r in rows + ] + total = _row( + "all", + sum(r["useful"] for r in out_rows), + sum(r["ignored"] for r in out_rows), + sum(r["misled"] for r in out_rows), + sum(r["overlooked"] for r in out_rows), + ) + return { + "rows": out_rows, + "total": total, + "rated_sessions": int(sessions or 0), + "useful_per_session": (total["useful"] / sessions) if sessions else 0.0, + } diff --git a/better_memory/ui/templates/diagnostics.html b/better_memory/ui/templates/diagnostics.html index 3b9b882..f1508af 100644 --- a/better_memory/ui/templates/diagnostics.html +++ b/better_memory/ui/templates/diagnostics.html @@ -40,6 +40,35 @@

Recent ratings

+
+

Useful rate by channel

+

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

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

{{ label }}

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

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

+ {% endfor %} +
+

Rating diagnostics

diff --git a/better_memory/ui/templates/fragments/reflection_drawer.html b/better_memory/ui/templates/fragments/reflection_drawer.html index 3d61a2e..757fff6 100644 --- a/better_memory/ui/templates/fragments/reflection_drawer.html +++ b/better_memory/ui/templates/fragments/reflection_drawer.html @@ -34,6 +34,19 @@

Use cases

{{ detail.reflection.use_cases }}

+ {% if caps.supports_triggers %} +
+

Triggers

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

No triggers: served by the prompt gate only.

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

Hints

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

Edit: {{ detail.reflection.title }}

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