Skip to content
Merged
17 changes: 12 additions & 5 deletions .claude/skills/rate-session-memories/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ already been credited via `memory.credit` mid-session.

## STEP 1 — Refresh the list

Call `memory.list_session_exposures` (no arguments — the server resolves the
current session from env). Read the returned list. This is the ONLY valid set
of ids to rate. The list in the RATE_MEMORIES directive may have been truncated.
Read the `Session:` line from the RATE_MEMORIES directive (its second line,
e.g. `Session: <id>`). If present, call `memory.list_session_exposures` with
`{"session_id": "<that id>"}`. If the directive has no `Session:` line (older
hook), call `memory.list_session_exposures` with no arguments — the server
resolves the current session from env, as before.

Read the returned list. This is the ONLY valid set of ids to rate. The list
in the RATE_MEMORIES directive may have been truncated.

## STEP 2 — Evidence first, then classify

Expand Down Expand Up @@ -42,11 +47,13 @@ Rules:

## STEP 3 — Submit ALL ratings in ONE call

Call `memory.apply_session_ratings` with this exact shape (no `session_id`
— the server resolves it):
Call `memory.apply_session_ratings` with the same `session_id` from STEP 1
alongside `ratings` (omit `session_id` if the directive had no `Session:`
line — the server then resolves it as before):

```json
{
"session_id": "<that id>",
"ratings": [
{"kind": "reflection", "id": "r-abc...", "class": "cited",
"evidence": "Used its junit-xml flag verbatim in the pytest invocation."},
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,13 @@ The server registers 22 tools, grouped below. Full schemas are in [`website/mcp-
| `memory.synthesize_next_get_context(project?)` | One pending episode's full context for the IDE-LLM to act on. |
| `memory.synthesize_next_apply(episode_id, decision, project?)` | Atomically apply Claude's `new` / `augment` / `merge` / `ignore` decision. |

**Rating** — closed-loop reinforcement on top of `memory.record_use`. Session id is resolved server-side from `CLAUDE_SESSION_ID`; none of these tools take a session id.
**Rating** — closed-loop reinforcement on top of `memory.record_use`. Session id is resolved server-side from `CLAUDE_SESSION_ID` by default; `list_session_exposures` and `apply_session_ratings` also accept an explicit `session_id` (from the RATE_MEMORIES directive's `Session:` line), which overrides env/marker resolution. `memory.credit` takes no session id.

| Tool | Purpose |
|---|---|
| `memory.credit(kind, id, class, evidence)` | Opportunistic per-tool-use credit. `class` ∈ `cited` / `shaped` / `misled` / `overlooked` (not `ignored`). `evidence` is required: one line — what the memory changed, or a quote. If you can't write one, the memory was ignored; don't call credit. |
| `memory.list_session_exposures()` | Unrated exposure rows for the current session. Read-only; used by the `rate-session-memories` skill. |
| `memory.apply_session_ratings(ratings)` | Atomic end-of-session batch rating. Each entry: `{kind, id, class, evidence?}` — evidence is required for every non-`ignored` class (write the evidence line first; nothing to point at means the class is `ignored`). Violating batches are rejected whole. |
| `memory.list_session_exposures(session_id?)` | Unrated exposure rows for the current session. Read-only; used by the `rate-session-memories` skill. |
| `memory.apply_session_ratings(ratings, session_id?)` | Atomic end-of-session batch rating. Each entry: `{kind, id, class, evidence?}` — evidence is required for every non-`ignored` class (write the evidence line first; nothing to point at means the class is `ignored`). Violating batches are rejected whole. |

**Knowledge** — human-authored markdown corpus.

Expand Down
3 changes: 2 additions & 1 deletion better_memory/hooks/session_close.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def _emit_rating_directive_if_unrated(session_id: str) -> bool:
sections = [
f"RATE_MEMORIES: {len(rows)} unrated. "
"Invoke skill `rate-session-memories`.",
f"Session: {session_id}",
"Evidence line first; none possible = `ignored`.",
]
if refl_lines:
Expand All @@ -124,7 +125,7 @@ def _emit_rating_directive_if_unrated(session_id: str) -> bool:
if len(encoded) > CAP_BYTES:
directive = encoded[: CAP_BYTES - 200].decode("utf-8", errors="ignore") + (
"\n\n(list truncated; call memory.list_session_exposures "
"for the full set)"
"with this session id for the full set)"
)

payload = {
Expand Down
6 changes: 4 additions & 2 deletions better_memory/mcp/handlers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ async def session_bootstrap(self, args: dict[str, Any]) -> list[TextContent]:
async def list_session_exposures(
self, args: dict[str, Any]
) -> list[TextContent]:
sid = resolve_session_id(self._home) or ""
explicit = str(args.get("session_id") or "").strip()
sid = explicit or resolve_session_id(self._home) or ""
if self._remote is not None:
payload = self._remote.list_session_exposures(session_id=sid)
return [TextContent(type="text", text=json.dumps(payload))]
Expand All @@ -114,7 +115,8 @@ async def list_session_exposures(
async def apply_session_ratings(
self, args: dict[str, Any]
) -> list[TextContent]:
sid = resolve_session_id(self._home)
explicit = str(args.get("session_id") or "").strip()
sid = explicit or resolve_session_id(self._home)
if not sid:
raise ValueError(
"No active session: CLAUDE_SESSION_ID / "
Expand Down
39 changes: 28 additions & 11 deletions better_memory/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,33 +460,50 @@ def tool_definitions(
description=(
"Return the unrated session_memory_exposure rows for the "
"current Claude session (resolved server-side from "
"CLAUDE_SESSION_ID env). Read-only; no side effects. "
"Used by the rate-session-memories skill as the "
"authoritative anti-hallucination list."
"CLAUDE_SESSION_ID env, or from an explicit session_id "
"argument). Read-only; no side effects. Used by the "
"rate-session-memories skill as the authoritative "
"anti-hallucination list."
),
inputSchema={
"type": "object",
"additionalProperties": False,
"properties": {},
"properties": {
"session_id": {
"type": "string",
"description": (
"Explicit session id from the RATE_MEMORIES "
"directive; overrides env/marker resolution."
),
},
},
},
),
Tool(
name="memory.apply_session_ratings",
description=(
"Atomic batch rating for the current Claude session "
"(resolved server-side from CLAUDE_SESSION_ID). Called "
"at session end by the rate-session-memories skill. "
"Raises if CLAUDE_SESSION_ID is unset — call only inside "
"an active Claude session. Non-ignored classes require "
"`evidence` (one line: what the memory changed, or a "
"quote); write the evidence line FIRST — no evidence "
"means the class is `ignored`."
"(resolved server-side from CLAUDE_SESSION_ID, or from an "
"explicit session_id argument). Called at session end by "
"the rate-session-memories skill. Raises if no session "
"can be resolved — call only inside an active Claude "
"session. Non-ignored classes require `evidence` (one "
"line: what the memory changed, or a quote); write the "
"evidence line FIRST — no evidence means the class is "
"`ignored`."
),
inputSchema={
"type": "object",
"additionalProperties": False,
"required": ["ratings"],
"properties": {
"session_id": {
"type": "string",
"description": (
"Explicit session id from the RATE_MEMORIES "
"directive; overrides env/marker resolution."
),
},
"ratings": {
"type": "array",
"minItems": 1,
Expand Down
73 changes: 73 additions & 0 deletions docs/superpowers/plans/2026-08-31-rate-memories-session-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# RATE_MEMORIES Session-Id Pass-Through Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** The RATE_MEMORIES flow becomes deterministic end-to-end: the Stop-hook directive names the session id it counted, and the rating tools accept that id explicitly instead of guessing from env/marker.

**Architecture:** Three small changes along one data path: `hooks/session_close.py` (directive text), `mcp/tools.py` + `mcp/handlers/sessions.py` (optional `session_id` param, explicit > env > marker), `.claude/skills/rate-session-memories/SKILL.md` (pass it through). Docs ship in the same PR.

**Tech Stack:** Python 3.12 stdlib; pytest; ruff.

**Spec:** Root-cause record: observation `0000001788170115314#bf82ddc6` (2026-08-31). Summary: hook-side writers key exposures to the stdin-payload session id; the MCP server resolves env-first from its spawn env (which can hold a different id after an MCP respawn), and the per-project marker file loses last-writer-wins races between concurrent sessions. Result: `list_session_exposures` returns empty while the Stop hook counts N pending, forever.

## Global Constraints

- Hooks never raise and never block beyond the existing decision:block contract.
- Backward compatible: both tools keep working with NO `session_id` argument (existing resolution unchanged, fallback path).
- MCP tool COUNT unchanged (schema edits only) — do not touch tool-count claims in docs.
- Lint/test gates per touched dirs: `uv run ruff check <touched>` clean (pre-existing E501 in tests/hooks/test_post_commit.py:169 is not yours); focused pytest per task; full `uv run pytest -q` once before the branch review.
- Commit messages end with: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`

---

### Task 1: Directive names its session id (confidence 95%)

**Files:**
- Modify: `better_memory/hooks/session_close.py` (in `_emit_rating_directive_if_unrated`, sections list ~line 111)
- Test: extend `tests/hooks/test_session_close_rating_directive.py`

**Interfaces:**
- Consumes: existing `_emit_rating_directive_if_unrated(session_id)`.
- Produces: directive `additionalContext` whose SECOND line is exactly `Session: <session_id>` (after the `RATE_MEMORIES: N unrated...` header line); the truncation suffix message becomes `(list truncated; call memory.list_session_exposures with this session id for the full set)`.

- [ ] **Step 1: Failing tests** — extend the existing directive tests: (a) directive contains line `Session: abc-123` when called with that id; (b) the `Session:` line survives truncation (build >8KB of rows, assert the line is still present in the truncated output — it is in the header section before the cap slice). Follow the file's existing seeding/capture style.
- [ ] **Step 2: Run to verify fail** — `uv run pytest tests/hooks/test_session_close_rating_directive.py -q` → new tests FAIL.
- [ ] **Step 3: Implement** — insert `f"Session: {session_id}"` as the second element of `sections` (between the header string and the evidence-first line); update the truncation suffix text.
- [ ] **Step 4: Pass + lint** — same pytest command PASS; `uv run ruff check better_memory/hooks tests/hooks`.
- [ ] **Step 5: Commit** — `fix(hooks): RATE_MEMORIES directive names the session id it counted`

### Task 2: Rating tools accept explicit session_id (confidence 92%)

**Files:**
- Modify: `better_memory/mcp/tools.py` (~:459 `memory.list_session_exposures`, ~:474 `memory.apply_session_ratings` inputSchemas)
- Modify: `better_memory/mcp/handlers/sessions.py` (the two handler methods; read their current session-resolution first)
- Test: extend the existing handler tests (locate via `grep -rl list_session_exposures tests/`)

**Interfaces:**
- Consumes: existing handler resolution (env/marker via `_common`/`session_marker`).
- Produces: both tools accept optional `session_id: string` (schema: `{"type": "string"}`, NOT required). Resolution order in BOTH handlers: explicit argument (non-empty, stripped) > existing env/marker resolution. Empty/whitespace argument = absent.

- [ ] **Step 1: Failing tests** — for each handler: seed exposures under session `S1`; call handler with `session_id="S1"` while the ambient resolution points elsewhere (monkeypatch env/marker per the existing tests' style) → rows returned / ratings applied; and one test that omitting the param preserves current behavior (regression guard). Follow existing handler-test fixtures.
- [ ] **Step 2: Run to verify fail** — focused pytest on the touched test files → FAIL (unknown argument or empty result).
- [ ] **Step 3: Implement** — schema: add `session_id` property + description ("Explicit session id from the RATE_MEMORIES directive; overrides env/marker resolution"). Handlers: `explicit = str(arguments.get("session_id") or "").strip()` then `session_id = explicit or <existing resolution>`.
- [ ] **Step 4: Pass + lint** — focused pytest PASS; `uv run ruff check better_memory/mcp tests`.
- [ ] **Step 5: Commit** — `fix(mcp): list/apply session-rating tools accept explicit session_id`

### Task 3: Skill pass-through + docs sweep (confidence 93%)

**Files:**
- Modify: `.claude/skills/rate-session-memories/SKILL.md` (repo copy — user-scope is a link to it)
- Modify: `website/mcp-tools.md` (the two tools' schema sections), `README.md` ONLY if it details these two tools' parameters (grep first; tool count untouched)

**Interfaces:** none (prose), but every parameter name copied verbatim from the Task 2 schemas in `mcp/tools.py`.

- [ ] **Step 1: Skill update** — STEP 1: "Read the `Session:` line from the RATE_MEMORIES directive; call `memory.list_session_exposures` with `{"session_id": "<that id>"}`. If the directive has no Session line (older hook), call with no arguments as before." STEP 3: apply call includes the same `session_id`. Keep the anti-hallucination rule (authoritative list) and evidence-first rules intact.
- [ ] **Step 2: Docs** — update the two tools' parameter tables/schemas in `website/mcp-tools.md`, copying names from `mcp/tools.py`. Verify README mentions (grep `list_session_exposures` in README.md) and update only if parameters are shown.
- [ ] **Step 3: Gates + full suite** — `uv run pytest -q` full run green (exact counts in report); `uv run ruff check better_memory` clean for touched dirs.
- [ ] **Step 4: Commit** — `docs(skill): rate-session-memories passes the directive session id`

## Self-review record

- Coverage: root cause has three legs (env drift, marker race, no explicit channel); the fix adds the explicit channel end-to-end (T1 producer → T2 consumer → T3 instructions) and leaves fallbacks untouched (constraint). Docs-sync guardrail satisfied by T3.
- Placeholders: none — exact line texts, resolution order, and schema shapes specified; handler internals deliberately referenced by grep because current signatures are read at implementation time (both tasks instruct reading first).
- Type consistency: `session_id` string param named identically in schema, handler, skill, and directive line.
70 changes: 70 additions & 0 deletions tests/hooks/test_session_close_rating_directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,34 @@ def _seed_unrated_exposure(db_path: Path, sid: str = "S1", source: str = "bootst
c.close()


def _seed_many_unrated(db_path: Path, sid: str, count: int):
"""Seed ``count`` distinct reflections + unrated exposures for ``sid``,
each with a long title so the rendered directive comfortably exceeds
the hook's 8KB truncation cap."""
c = connect(db_path)
apply_migrations(c)
long_title = "T" * 80
for i in range(count):
rid = f"r{i}"
c.execute(
"""INSERT INTO reflections
(id, title, project, phase, polarity, use_cases, hints,
confidence, created_at, updated_at)
VALUES (?, ?, 'p', 'general', 'do', 'uc', '[]', 0.5,
'2026-01-01', '2026-01-01')""",
(rid, long_title),
)
c.execute(
"""INSERT INTO session_memory_exposure
(session_id, memory_kind, memory_id, exposed_at, source)
VALUES (?, 'reflection', ?, '2026-05-11T11:00:00+00:00',
'bootstrap')""",
(sid, rid),
)
c.commit()
c.close()


def _seed_semantic_exposure(db_path: Path, sid: str = "S1", source: str = "contextual"):
"""Seed a semantic-memory unrated exposure. Assumes migrations already
applied (call after _seed_unrated_exposure, which applies them)."""
Expand Down Expand Up @@ -298,3 +326,45 @@ def test_empty_reflection_bucket_omitted(self, tmp_path, tmp_memory_db):
directive = payload["hookSpecificOutput"]["additionalContext"]
assert "Reflections (" not in directive
assert "My Semantic Fact" in directive

def test_directive_names_the_session_id_it_counted(
self, tmp_path, tmp_memory_db,
):
"""The MCP rating tools can't reliably re-derive the session id the
Stop hook counted against (env drift after MCP respawn + marker
races), so the directive must name it explicitly, as the second
line, right after the RATE_MEMORIES header."""
_seed_unrated_exposure(tmp_memory_db, "S1")
env = {
"BETTER_MEMORY_HOME": str(tmp_memory_db.parent),
"CLAUDE_SESSION_ID": "S1",
}
result = _run_hook(env)
assert result.returncode == 0
payload = json.loads(result.stdout)
directive = payload["hookSpecificOutput"]["additionalContext"]
lines = directive.split("\n")
assert lines[0].startswith("RATE_MEMORIES:")
assert lines[1] == "Session: S1"

def test_session_line_survives_truncation(self, tmp_path, tmp_memory_db):
"""Seed enough unrated rows to blow past the 8KB truncation cap and
confirm the ``Session:`` line (which precedes the capped slice)
survives, and that the truncation message points the LLM at
list_session_exposures with the id it should use."""
_seed_many_unrated(tmp_memory_db, "S1", 200)
env = {
"BETTER_MEMORY_HOME": str(tmp_memory_db.parent),
"CLAUDE_SESSION_ID": "S1",
}
result = _run_hook(env)
assert result.returncode == 0
payload = json.loads(result.stdout)
directive = payload["hookSpecificOutput"]["additionalContext"]
# Confirm truncation actually happened so this test is meaningful.
assert "list truncated" in directive
assert "Session: S1" in directive.split("\n")
assert (
"(list truncated; call memory.list_session_exposures "
"with this session id for the full set)"
) in directive
Loading