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
1 change: 1 addition & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Auto-capture configuration for MCP server.
| `capture_insights` | `bool` | `true` | |
| `capture_preferences` | `bool` | `true` | |
| `min_confidence` | `float` | `0.7` | |
| `capture_session_summary` | `bool` | `false` | |

## `[eternal]`

Expand Down
8 changes: 6 additions & 2 deletions src/surreal_memory/hooks/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,12 +501,16 @@ async def capture_text(text: str, project_name: str | None = None) -> dict[str,
logger.debug("Failed to save stop-hook memory", exc_info=True)
continue

# Always save a session summary if no patterns were detected. Skipped
# Fall back to a session summary if no patterns were detected. Skipped
# when every detected fragment was duplicate-filtered above -- that
# case already has an accurate "duplicate" status; falling through
# here would silently replace it with a brand-new summary save.
#
# Off by default (auto.capture_session_summary): the "summary" is the last
# ~10 transcript lines verbatim, which on a real chat is harness markers and
# half-sentences, not knowledge. See AutoConfig for the measured numbers.
summary_had_candidate = False
if not saved and not all_candidates_were_duplicates:
if config.auto.capture_session_summary and not saved and not all_candidates_were_duplicates:
summary = _extract_session_summary(text)
if summary:
summary_had_candidate = True
Expand Down
17 changes: 17 additions & 0 deletions src/surreal_memory/unified_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ class AutoConfig:
capture_insights: bool = True
capture_preferences: bool = True
min_confidence: float = 0.7
# Fallback in the Stop hook that manufactures a candidate out of the last ~10
# lines of the transcript when pattern extraction found nothing. It is a
# truncation, not a summary, so on a real chat transcript it yields harness
# markers and half-sentences. Measured on a production brain over 24 h: 96 of
# 158 write-gate rejections (61 %) originated in this path alone -- entries such
# as "Session activity: <task-notification>" or "Session activity: Writing
# objects:". Default off because on a stock installation nothing stops that
# output: WriteGateConfig defaults to enabled=False / mode="off", so the gate
# does not reject it -- it is stored as a CONTEXT memory. Where the gate IS
# enabled it rejects the same entries and the fallback only produces telemetry
# noise, so off is the better default either way. Set true to restore the
# previous behaviour; the #80 idempotency regression tests exercise it
# deliberately.
capture_session_summary: bool = False

def to_dict(self) -> dict[str, Any]:
return {
Expand All @@ -94,6 +108,7 @@ def to_dict(self) -> dict[str, Any]:
"capture_insights": self.capture_insights,
"capture_preferences": self.capture_preferences,
"min_confidence": self.min_confidence,
"capture_session_summary": self.capture_session_summary,
}

@classmethod
Expand All @@ -107,6 +122,7 @@ def from_dict(cls, data: dict[str, Any]) -> AutoConfig:
capture_insights=data.get("capture_insights", True),
capture_preferences=data.get("capture_preferences", True),
min_confidence=data.get("min_confidence", 0.7),
capture_session_summary=data.get("capture_session_summary", False),
)


Expand Down Expand Up @@ -2135,6 +2151,7 @@ def save(self) -> None:
f"capture_insights = {'true' if self.auto.capture_insights else 'false'}",
f"capture_preferences = {'true' if self.auto.capture_preferences else 'false'}",
f"min_confidence = {self.auto.min_confidence}",
f"capture_session_summary = {'true' if self.auto.capture_session_summary else 'false'}",
"",
"# Eternal context settings",
"[eternal]",
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_hook_capture_idempotency.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ def _isolate_storage_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SURREALDB_PASS", raising=False)


def _enable_session_summary() -> None:
"""Opt this suite into the session-summary fallback, which now defaults off.

``_TEXT_A``/``_TEXT_B`` carry no ``Decision:``/``Error:``/``TODO:`` markers on
purpose, so pattern extraction finds nothing and the single saved memory is the
summary itself -- that is exactly the re-encode this file was written to pin
(upstream #80). ``AutoConfig.capture_session_summary`` became False by default
because on a production transcript that fallback emits harness markers rather
than knowledge; the defect it guards is real either way, so the suite turns the
path back on rather than weakening its assertions.
"""
from surreal_memory.unified_config import get_config

get_config().auto.capture_session_summary = True


@pytest.fixture
async def isolated_brain(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str:
"""Point unified_config at a throwaway HOME with a unique brain+session.
Expand All @@ -98,6 +114,7 @@ async def isolated_brain(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str
from surreal_memory.unified_config import get_config

get_config(reload=True)
_enable_session_summary()
return brain_name


Expand All @@ -121,6 +138,7 @@ async def isolated_brain_gate_enforce(tmp_path: Path, monkeypatch: pytest.Monkey
from surreal_memory.unified_config import get_config

get_config(reload=True)
_enable_session_summary()
return brain_name


Expand Down Expand Up @@ -226,6 +244,7 @@ async def test_no_session_id_still_captures_and_dedupes(
from surreal_memory.unified_config import get_config

get_config(reload=True)
_enable_session_summary()

result1 = await stop_hook.capture_text(_TEXT_A, project_name=None)
assert result1["saved"] == 1
Expand Down Expand Up @@ -259,6 +278,17 @@ async def test_concurrent_calls_never_crash_or_silently_ambiguous(
import subprocess
import sys

# The two racers are separate OS processes, so the in-process opt-in from
# _enable_session_summary() does not reach them -- they re-read config.toml
# under this test's HOME. Without this the fallback stays off there, both
# processes capture nothing, and the assertion below fails for a reason that
# has nothing to do with the race it is meant to measure.
surrealmemory_dir = tmp_path / ".surrealmemory"
surrealmemory_dir.mkdir(parents=True, exist_ok=True)
(surrealmemory_dir / "config.toml").write_text(
"\n[auto]\ncapture_session_summary = true\n", encoding="utf-8"
)

proj_dir = tmp_path / ".claude" / "projects" / "race-test"
proj_dir.mkdir(parents=True, exist_ok=True)
transcript = proj_dir / "transcript.jsonl"
Expand Down
133 changes: 133 additions & 0 deletions tests/unit/test_stop_hook_session_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""The Stop hook's session-summary fallback must stay OFF unless asked for.

Why this file exists. ``_extract_session_summary()`` does not summarise: it takes
the last ~10 non-trivial lines of the transcript verbatim, joins them and prefixes
"Session activity: ". On prose that reads fine; on a real agent transcript it emits
harness markers and half-sentences. Measured on the production brain over 24 h,
96 of 158 write-gate rejections (61 %) came from this one path -- rows such as
``Session activity: <task-notification>`` and ``Session activity: Writing objects:``.

On a stock installation the gate is off (``WriteGateConfig`` defaults to
``enabled=False`` / ``mode="off"``), so those rows are not discarded -- they are
stored as ``CONTEXT`` memories. Where the gate is enabled it rejects them and the
fallback reliably produced telemetry noise plus the occasional truncated TODO
that scraped past the threshold and came back as session-start context. Hence the
default flip. These tests pin BOTH directions, because a default that nothing
asserts is a default that silently drifts back:

* off (the new default) -- a pattern-free transcript stores nothing, and the
returned message says so distinguishably;
* on (explicit opt-in) -- the old behaviour still works, so #80's regression
suite and anyone relying on it keep a supported path.
"""

from __future__ import annotations

import uuid
from pathlib import Path

import pytest

from surreal_memory.hooks import stop as stop_hook

# No "Decision:"/"Error:"/"TODO:" markers anywhere, so analyze_text_for_memories()
# finds nothing and the summary fallback is the only path that could save anything.
# Lines are >15 chars so _extract_session_summary() would produce a non-empty
# candidate if it ran -- otherwise "saved 0" would prove nothing.
_TEXT_NO_PATTERNS = (
"Here is an overview of the staging cluster network topology for reference purposes.\n\n"
"The quarterly release checklist includes twelve items across three teams total."
)


@pytest.fixture(autouse=True)
def _isolate_storage_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Same pin as test_hook_capture_idempotency: a dev shell that exports
SURREAL_MEMORY_STORAGE=surrealdb would otherwise aim these writes at the live
brain instead of the throwaway in-memory one."""
monkeypatch.setenv("SURREAL_MEMORY_STORAGE", "memory")
monkeypatch.delenv("SURREAL_MEMORY_DIR", raising=False)
monkeypatch.delenv("SURREALDB_URL", raising=False)
monkeypatch.delenv("SURREALDB_PASS", raising=False)


@pytest.fixture
async def isolated_brain(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("TMPDIR", "/tmp")
brain_name = "summ" + uuid.uuid4().hex[:12]
monkeypatch.setenv("SURREAL_MEMORY_BRAIN", brain_name)
monkeypatch.setenv("CLAUDE_SESSION_ID", "sess-" + uuid.uuid4().hex[:12])

from surreal_memory.unified_config import get_config

get_config(reload=True)
return brain_name


class TestSessionSummaryDefault:
def test_default_is_off(self) -> None:
from surreal_memory.unified_config import AutoConfig

assert AutoConfig().capture_session_summary is False

def test_absent_key_loads_as_off(self) -> None:
"""A config.toml written before this flag existed must not switch the
fallback back on just because the key is missing."""
from surreal_memory.unified_config import AutoConfig

assert AutoConfig.from_dict({"enabled": True}).capture_session_summary is False

def test_explicit_true_survives_round_trip(self) -> None:
"""to_dict/from_dict must carry the opt-in, or `smem doctor --fix` (which
re-saves the whole config) would quietly reset an operator's choice."""
from surreal_memory.unified_config import AutoConfig

enabled = AutoConfig(capture_session_summary=True)
assert AutoConfig.from_dict(enabled.to_dict()).capture_session_summary is True

def test_save_emits_the_key(self, tmp_path: Path) -> None:
"""The flag must reach config.toml on disk. If save() omitted it, the
value would live only in memory and vanish on the next load."""
import tomllib

from surreal_memory.unified_config import UnifiedConfig

config = UnifiedConfig(data_dir=tmp_path)
config.auto.capture_session_summary = True
config.save()

raw = tomllib.loads((tmp_path / "config.toml").read_text(encoding="utf-8"))
assert raw["auto"]["capture_session_summary"] is True


@pytest.mark.asyncio
class TestSessionSummaryBehaviour:
async def test_off_stores_nothing_for_pattern_free_text(self, isolated_brain: str) -> None:
result = await stop_hook.capture_text(_TEXT_NO_PATTERNS, project_name=None)

assert result["saved"] == 0, (
"with the fallback off, a transcript holding no memory patterns must "
f"store nothing; got {result['memories']!r}"
)
message = result["message"].lower()
assert "no memorable content" in message, (
f"saved=0 must name the reason distinguishably, got: {result['message']!r}"
)
# Not a gate rejection and not a duplicate skip: nothing was ever offered.
assert "gate" not in message and "rejected" not in message
assert "duplicate" not in message and "idempot" not in message

async def test_on_still_stores_the_summary(self, isolated_brain: str) -> None:
"""Positive control. Without this, `saved == 0` above would also pass if
the hook were broken for an unrelated reason."""
from surreal_memory.unified_config import get_config

get_config().auto.capture_session_summary = True

result = await stop_hook.capture_text(_TEXT_NO_PATTERNS, project_name=None)

assert result["saved"] == 1, (
f"opt-in must restore the old behaviour, got {result['message']!r}"
)
assert result["memories"][0].startswith("Session activity:")
Loading