Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions better_memory/db/migrations/0017_exposure_display.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Migration 0017: display-text snapshot on rated exposures.
--
-- Display-text snapshot captured at exposure-record time. Nullable: rows
-- written before this migration (and callers with no display in hand) leave
-- it NULL and the read path falls back to joining reflections.title /
-- semantic_memories.content — which only resolves local-table ids, not
-- agentcore's AWS-minted mem-<uuid> ids. See
-- docs/superpowers/specs/2026-08-06-rate-directive-display-design.md.

ALTER TABLE session_memory_exposure ADD COLUMN display TEXT;
3 changes: 2 additions & 1 deletion better_memory/hooks/contextual_inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,11 @@ def main() -> None:
if items:
rendered = format_relevant(items)
survivors = [(m.kind, m.id) for m in items]
exposure_items = [(m.kind, m.id, m.text) for m in items]
try:
backend.record_exposures(
session_id=session_id,
items=survivors,
items=exposure_items,
source="contextual",
)
except BaseException as exc: # noqa: BLE001 - never block injection
Expand Down
52 changes: 23 additions & 29 deletions better_memory/hooks/session_close.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,25 @@ def _emit_rating_directive_if_unrated(session_id: str) -> bool:
return False
conn = connect(cfg.memory_db)
try:
# Dedupe by (memory_kind, memory_id) — a memory can have two
# exposure rows (bootstrap + retrieve) in one session. The
# MIN(exposed_at) keeps deterministic ordering; the rating
# apply path stamps ALL unrated rows per (kind, id) in one
# UPDATE, so one rating per unique memory is the correct
# Dedupe by (memory_kind, memory_id) — every current writer
# enforces at most one exposure row per (session, kind, id) via
# a NOT-EXISTS guard, so duplicates here can only be legacy rows
# predating that guard. The GROUP BY handles those legacy
# duplicates; MIN(exposed_at) keeps deterministic ordering, and
# the rating apply path stamps ALL unrated rows per (kind, id)
# in one UPDATE, so one rating per unique memory is the correct
# contract to surface to the LLM.
# Standalone copy, intentionally not delegated to
# better_memory.services.exposure_log.list_unrated (see that
# module for the shared implementation) — kept inline here so
# this hook has no service-layer import dependency.
# this hook has no service-layer import dependency. Uses the
# display snapshot column (Task 1) to support foreign IDs.
rows = conn.execute(
"""
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
COALESCE(MAX(e.display), r.title, s.content) AS display
FROM session_memory_exposure e
LEFT JOIN reflections r ON e.memory_kind='reflection'
AND e.memory_id = r.id
Expand All @@ -96,36 +99,27 @@ 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}"
line = f"- {r['memory_id']} [{source}] {display}".rstrip()
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())
)

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"
f"Reflections ({len(refl_lines)}):\n"
+ ("\n".join(refl_lines) if refl_lines else " (none)")
+ f"\n\nSemantic memories ({len(sem_lines)}):\n"
+ ("\n".join(sem_lines) if sem_lines else " (none)")
+ "\n\n"
"For each id: FIRST write one line of evidence (what the memory "
"changed, or a quote) - if you cannot, the class is `ignored`.\n"
"Classes: cited / shaped / ignored / misled / overlooked.\n"
"Non-ignored ratings without an evidence line are rejected. "
"Invoke the skill `rate-session-memories`."
)
sections = [
f"RATE_MEMORIES: {len(rows)} unrated. "
"Invoke skill `rate-session-memories`.",
"Evidence line first; none possible = `ignored`.",
]
if refl_lines:
sections.append(f"Reflections ({len(refl_lines)}):")
sections.extend(refl_lines)
if sem_lines:
sections.append(f"Semantic ({len(sem_lines)}):")
sections.extend(sem_lines)
directive = "\n".join(sections)
encoded = directive.encode("utf-8")
if len(encoded) > CAP_BYTES:
directive = encoded[: CAP_BYTES - 200].decode("utf-8", errors="ignore") + (
Expand Down
67 changes: 46 additions & 21 deletions better_memory/services/exposure_log.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
"""Shared exposure-ledger SQL: one implementation for every storage backend.

Three primitives, lifted verbatim from their original call sites so sqlite
behaviour stays byte-identical (this module is a pure extraction, pinned by
``tests/services/test_exposure_log.py`` + the four other suites listed in its
docstring):
Three primitives, largely lifted from their original call sites (this module is
pinned by ``tests/services/test_exposure_log.py`` + the four other suites
listed in its docstring). Extended with display-column snapshot support:
``record`` writes a ``display`` column (title/content captured at exposure time,
truncated to 120 chars); ``list_unrated`` COALESCEs the snapshot over joined
title/content. These extensions mean the module is no longer byte-identical to
the original inline SQL:

- ``record``: the first-source-wins ``INSERT..WHERE NOT EXISTS`` dedup guard
originally inline in ``SessionBootstrapService.record_exposures``
(``session_bootstrap.py``), extended with an ``exploration_ids`` param that
mirrors the ``via_exploration`` write in
``ReflectionSynthesisService.retrieve_reflections`` (``reflection.py``).
Passing no ``exploration_ids`` (the default, an empty frozenset) reproduces
the original ``record_exposures`` behaviour exactly.
the original ``record_exposures`` behaviour exactly. Extended with ``display``
column to snapshot title/content at exposure time, truncated to 120 chars —
needed because agentcore memory ids do not exist in local content tables.
- ``list_unrated``: the grouped/deduped/display-joined query originally
inline in ``SessionBootstrapService.list_session_exposures``.
inline in ``SessionBootstrapService.list_session_exposures``, extended to
COALESCE the snapshot ``display`` column over the joined title/content
for display values.
- ``stamp``: the exposure-row ``UPDATE`` originally inline in
``MemoryRatingService._apply_one``. Copied, not rewired — the sqlite
rating service keeps its own inline copy; this one is for the agentcore
Expand All @@ -23,28 +30,36 @@
existing call site already commits after invoking the original inline SQL,
so callers must continue to do so here.
"""

from __future__ import annotations

import sqlite3
from collections.abc import Sequence

_DISPLAY_TRUNC = 120


def record(
conn: sqlite3.Connection,
*,
session_id: str,
items: list[tuple[str, str]],
items: Sequence[tuple[str, str, str | None]],
source: str,
now: str,
exploration_ids: frozenset[str] = frozenset(),
) -> None:
"""Write one ``session_memory_exposure`` row per (kind, id) item.
"""Write one ``session_memory_exposure`` row per (kind, id, display) item.

At most one row per (session, kind, id), regardless of how many times
the memory is re-served within the session — first source (and first
``via_exploration`` value) wins; a later call for an already-exposed
(session, kind, id) is a no-op, even if it would have tagged the row as
an exploration serve.

Display is a snapshot of the memory's title/content captured at exposure
time (None when the caller has none), truncated to 120 chars — needed
because agentcore memory ids do not exist in local content tables.

Best-effort: no-op when ``session_id`` or ``items`` is empty. Does not
commit — the caller owns the transaction.
"""
Expand All @@ -53,32 +68,42 @@ def record(
conn.executemany(
"INSERT INTO session_memory_exposure "
"(session_id, memory_kind, memory_id, exposed_at, source, "
" via_exploration) "
"SELECT ?, ?, ?, ?, ?, ? "
" via_exploration, display) "
"SELECT ?, ?, ?, ?, ?, ?, ? "
"WHERE NOT EXISTS ("
" SELECT 1 FROM session_memory_exposure "
" WHERE session_id = ? AND memory_kind = ? AND memory_id = ?)",
[
(
session_id, kind, mid, now, source,
session_id,
kind,
mid,
now,
source,
1 if mid in exploration_ids else 0,
session_id, kind, mid,
(display[:_DISPLAY_TRUNC] if display else None),
session_id,
kind,
mid,
)
for kind, mid in items
for kind, mid, display in items
],
)


def list_unrated(conn: sqlite3.Connection, *, session_id: str) -> list[sqlite3.Row]:
"""Return unrated exposure rows for ``session_id``, grouped by memory.

A memory can have two exposure rows (bootstrap + retrieve) in one
session; the rating apply path stamps ALL unrated rows per (kind, id) in
one UPDATE, so callers need one entry per unique memory rather than one
per raw row. Rows are grouped by (memory_kind, memory_id) with
``MIN(exposed_at)`` / ``MIN(source)`` for deterministic first-exposure
values, joined against ``reflections.title`` / ``semantic_memories.content``
for display, ordered by first exposure ascending, and rated rows
Every current writer enforces at most one exposure row per (session,
kind, id) via a NOT-EXISTS guard, so a memory having more than one row
here can only be a legacy duplicate predating that guard. The GROUP BY
handles those legacy duplicates; the rating apply path stamps ALL
unrated rows per (kind, id) in one UPDATE, so callers need one entry per
unique memory rather than one per raw row. Rows are grouped by
(memory_kind, memory_id) with ``MIN(exposed_at)`` / ``MIN(source)`` for
deterministic first-exposure values, joined against
``reflections.title`` / ``semantic_memories.content`` for display,
ordered by first exposure ascending, and rated rows
(``rated_at IS NOT NULL``) excluded.

Returns rows with columns: ``memory_kind``, ``memory_id``, ``exposed_at``,
Expand All @@ -89,7 +114,7 @@ def list_unrated(conn: sqlite3.Connection, *, session_id: str) -> list[sqlite3.R
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
COALESCE(MAX(e.display), r.title, s.content) AS display
FROM session_memory_exposure e
LEFT JOIN reflections r ON e.memory_kind='reflection'
AND e.memory_id = r.id
Expand Down
18 changes: 13 additions & 5 deletions better_memory/services/session_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"""
from __future__ import annotations

from collections.abc import Callable
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
Expand Down Expand Up @@ -147,10 +147,10 @@ def record_exposures(
self,
*,
session_id: str,
items: list[tuple[str, str]],
items: Sequence[tuple[str, str, str | None]],
source: str,
) -> None:
"""Write one session_memory_exposure row per (kind, id) item.
"""Write one session_memory_exposure row per (kind, id, display) triple.

At most one row per (session, kind, id), regardless of how many times
the memory is re-served within the session. The rating vocabulary
Expand Down Expand Up @@ -182,6 +182,8 @@ def _record_exposure(
session_id: str,
reflection_ids: list[str],
semantic_ids: list[str],
reflection_display: dict[str, str | None],
semantic_display: dict[str, str | None],
) -> None:
"""Write one row per injected memory into session_memory_exposure.

Expand All @@ -191,8 +193,10 @@ def _record_exposure(
"""
self.record_exposures(
session_id=session_id,
items=[("reflection", rid) for rid in reflection_ids]
+ [("semantic", sid) for sid in semantic_ids],
items=[("reflection", rid, reflection_display.get(rid))
for rid in reflection_ids]
+ [("semantic", sid, semantic_display.get(sid))
for sid in semantic_ids],
source="bootstrap",
)

Expand Down Expand Up @@ -286,6 +290,8 @@ def bootstrap(
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(
Expand Down Expand Up @@ -358,6 +364,8 @@ def bootstrap(
session_id=session_id,
reflection_ids=reflection_ids,
semantic_ids=semantic_ids,
reflection_display={r["id"]: r.get("title") for _, r in flat_reflections},
semantic_display={m.id: m.content for m in semantic},
)

return BootstrapResult(
Expand Down
13 changes: 8 additions & 5 deletions better_memory/storage/agentcore.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
import json
import sqlite3
import time
from collections.abc import Callable
from collections.abc import Callable, Sequence
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from uuid import uuid4
Expand Down Expand Up @@ -366,16 +366,19 @@ def _record_retrieve_exposures(
return
if not sid:
return
all_ids = [r["id"] for bucket in buckets.values() for r in bucket]
if not all_ids:
all_items = [
("reflection", r["id"], r.get("title"))
for bucket in buckets.values() for r in bucket
]
if not all_items:
return
try:
from better_memory.services import exposure_log

exposure_log.record(
self._local_conn,
session_id=sid,
items=[("reflection", rid) for rid in all_ids],
items=all_items,
source="retrieve",
now=datetime.now(UTC).isoformat(),
exploration_ids=frozenset(exploration_ids),
Expand Down Expand Up @@ -2077,7 +2080,7 @@ def record_exposures(
self,
*,
session_id: str,
items: list[tuple[str, str]],
items: Sequence[tuple[str, str, str | None]],
source: str,
) -> None:
"""Write exposures to the local ledger when one is available.
Expand Down
10 changes: 7 additions & 3 deletions better_memory/storage/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@

from __future__ import annotations

from collections.abc import Sequence
from typing import Any, Literal, Protocol, runtime_checkable


# Outcome aliases are DEFINED here (not imported from services) to avoid a
# `better_memory.storage → services` import edge in the public surface. The same
# literal set also exists in `services/observation.py:76` and `search/hybrid.py:33`;
Expand Down Expand Up @@ -378,10 +378,14 @@ def record_exposures(
self,
*,
session_id: str,
items: list[tuple[str, str]],
items: Sequence[tuple[str, str, str | None]],
source: str,
) -> None:
"""Record (kind, id) memory exposures for later rating.
"""Record (kind, id, display) memory exposures for later rating.

display is a snapshot of the memory's title/content at exposure
time (None when unavailable); it makes agentcore-id exposures
renderable without any local content row.

Backend-agnostic: both backends write the SAME local
``session_memory_exposure`` table via ``services.exposure_log``.
Expand Down
3 changes: 2 additions & 1 deletion better_memory/storage/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from __future__ import annotations

import sqlite3
from collections.abc import Sequence
from typing import Any

from better_memory.services.episode import EpisodeService
Expand Down Expand Up @@ -440,7 +441,7 @@ def record_exposures(
self,
*,
session_id: str,
items: list[tuple[str, str]],
items: Sequence[tuple[str, str, str | None]],
source: str,
) -> None:
self._session_bootstrap.record_exposures(
Expand Down
Loading