Skip to content

Commit fee8a8e

Browse files
committed
release(v0.2.1): user-visible SessionStart banner + doctor drift signal
Two follow-up gaps from v0.2.0 that turned out to matter once the banner shipped: (1) The v0.2.0 banner reached Claude Code's `additionalContext` (model context) but was INVISIBLE to the human user. Per Claude Code hooks docs, the `systemMessage` field is the documented dual-channel rendering — Claude Code displays it as the `SessionStart:startup says: <line>` row in the terminal alongside other hook output. v0.2.1 adds `systemMessage` to the JSON payload plus `user_message` for Cursor forward-compat (Cursor docs note the field is "accepted but not enforced" today). (2) `supamem doctor`'s install-drift detection (per-client managed-block version vs running CLI version) now flips the banner health flag to ⚠ in real time. Surfaces the "you upgraded supamem but your CLAUDE.md / .cursor rules still reference the old version" failure mode at session-open instead of waiting for the user to run doctor manually. Cheap to compute (small text reads, never raises); failures fall back to ✓ rather than blocking session-start. Suppress only the user-visible row with `SUPAMEM_BANNER_QUIET=1` (keeps context injection alive for the model). `SUPAMEM_BANNER_DISABLE=1` still kills both channels. 302 tests pass, ruff clean, twine PEP 639 license check passes. Refs: research-agent a5c666821a386ce71 — Claude Code hooks docs at code.claude.com/docs/en/hooks confirm `systemMessage` is the canonical dual-channel rendering for hook events. Cursor `user_message` is included for forward-compat per cursor.com/docs/hooks.
1 parent c638f1a commit fee8a8e

9 files changed

Lines changed: 130 additions & 13 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,7 @@ CI guard candidate: a script that fails when `README.md` changes without bumping
154154
- New config field: extend `config.py` Pydantic schema + bump default in `share/default.toml`
155155
- New hook target: add module under `src/supamem/hooks/<client>.py`, register in `cli.py hook` dispatcher
156156
- Failure in network code: blanket `except Exception: pass` is correct for non-essential probes (update_check); for indexing/retrieval, surface error to user via `err_console`
157+
158+
# BEGIN SUPAMEM v0.2.0 MANAGED BLOCK — DO NOT EDIT
159+
@~/.supamem/share/rules/dual-memory.md
160+
# END SUPAMEM v0.2.0 MANAGED BLOCK

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@
22

33
All notable changes to `supamem` will be documented in this file.
44

5+
## v0.2.1 — unreleased
6+
7+
Patch fixing two v0.2.0 banner gaps that turned out to matter:
8+
9+
### Added
10+
11+
- **User-visible SessionStart banner** — the v0.2.0 banner reached the
12+
model via `additionalContext` but was invisible to the user. Adds a
13+
`systemMessage` field to the SessionStart hook payload, which Claude
14+
Code renders as the `SessionStart:startup says: <line>` row in the
15+
terminal (officially documented dual-channel pattern). Cursor
16+
`user_message` is included for forward-compat (per Cursor docs the
17+
field is "accepted but not enforced" today; will surface once Cursor
18+
ships UI for it). Suppress only the user-visible row with
19+
`SUPAMEM_BANNER_QUIET=1` (keeps context injection alive for the
20+
model). `SUPAMEM_BANNER_DISABLE=1` still kills both channels.
21+
- **`supamem doctor` install-drift surfaced in the banner** — the
22+
health flag now flips to `` when any installed client's managed-
23+
block version differs from the running CLI (i.e. you upgraded
24+
supamem but a client's CLAUDE.md/.cursor rules still reference the
25+
old version). Prompts running `supamem repair` to resync. The drift
26+
probe is cheap (small text reads, never raises); banner failures
27+
fall back to `` rather than blocking session-start.
28+
529
## v0.2.0 — 2026-05-01
630

731
First milestone of the v0.2.0 token-economy line. Ships server-side hard

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,8 @@ You should see a colorful banner and the credit line. 🎨
295295
| `SUPAMEM_GATE_DISABLE=1` | Bypass the opt-in claude-code edit-gate for the current session (`--enforce-search` users only). |
296296
| `SUPAMEM_ADVISORY_DISABLE=1` | Suppress the Cursor `beforeSubmitPrompt` advisory hook. |
297297
| `SUPAMEM_NO_UPDATE_CHECK=1`, `NO_UPDATE_NOTIFIER=1`, `CI=1` | Suppress the GitHub Releases probe. |
298-
| `SUPAMEM_BANNER_DISABLE=1` | Suppress the SessionStart one-line banner. |
298+
| `SUPAMEM_BANNER_DISABLE=1` | Suppress the SessionStart one-line banner entirely (no context injection, no user-visible status). |
299+
| `SUPAMEM_BANNER_QUIET=1` | Suppress only the **user-visible** terminal status line; keep injecting the banner into Claude Code's `additionalContext` for the model. Use this when you want supamem context loaded but no per-session `SessionStart:supamem says: …` row in your terminal. |
299300

300301
### SessionStart banner format
301302

llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Format: `🧠 supamem ✓ v0.2.0 · <collection> · <N> chunks · audit <path>`
8787
- Health flag — single character right after `supamem`: `✓` healthy / `⚠` qdrant unreachable OR resolved collection is still the shipped default (legacy global-install / wrong-cwd failure mode)
8888
- Update hint — cache-only read of `update_check.json`; never blocks session-open on network. Healing is NEVER automatic — the banner only signals; run `supamem repair` to act
8989
- Suppress entirely with `SUPAMEM_BANNER_DISABLE=1`
90+
- Suppress ONLY the user-visible terminal line (keep injecting context for the model) with `SUPAMEM_BANNER_QUIET=1`. v0.2.1+ emits `systemMessage` (Claude Code) and `user_message` (Cursor forward-compat) alongside `additionalContext` — Claude Code renders `systemMessage` as the `SessionStart:startup says: <line>` row in the terminal. Health flag `⚠` now also fires on per-client install drift detected by `supamem doctor` (managed-block version != running CLI version).
9091

9192
## Update-check (v0.1.1+)
9293

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "supamem"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
description = "Project-agnostic dual-memory tooling for Claude Code, Cursor, and opencode"
99
readme = "README.md"
1010
license = "MIT"

src/supamem/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
"""supamem — project-agnostic dual-memory tooling."""
2-
__version__ = "0.2.0"
2+
__version__ = "0.2.1"

src/supamem/hooks/session_start.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ def _probe_health_flag(cfg: ResolvedConfig, points: int | None) -> str:
7676
* ``"⚠"`` when supamem looks misconfigured for THIS project:
7777
qdrant unreachable, OR the resolved collection is still the shipped
7878
default (``dev_memory_tuned_hybrid``) AND no project config was loaded
79-
(the legacy global-install failure mode).
79+
(the legacy global-install failure mode), OR per-client install drift
80+
(a client's managed-block version differs from the running CLI).
8081
* ``"✓"`` otherwise.
8182
"""
8283
if points is None:
@@ -86,9 +87,26 @@ def _probe_health_flag(cfg: ResolvedConfig, points: int | None) -> str:
8687
default_collection = ResolvedConfig().collection
8788
if cfg.collection == default_collection:
8889
return "⚠"
90+
if _has_install_drift():
91+
return "⚠"
8992
return "✓"
9093

9194

95+
def _has_install_drift() -> bool:
96+
"""True if any installed client's managed-block version differs from the
97+
running CLI version (``supamem doctor`` drift signal).
98+
99+
Cheap: reads small text files; never raises.
100+
"""
101+
try:
102+
from supamem.doctor import version_drift_report
103+
104+
return any(row.get("drift") for row in version_drift_report())
105+
except Exception as exc: # noqa: BLE001 — banner must never fail
106+
log.debug("session_start: drift probe failed: %s", exc)
107+
return False
108+
109+
92110
def _probe_update_hint() -> str | None:
93111
"""Return ``"update v0.X.Y available"`` if a newer release is cached, else None.
94112
@@ -161,16 +179,40 @@ def _detect_client() -> str:
161179

162180

163181
def _emit_payload(banner: str) -> dict[str, Any]:
164-
"""Dual-format JSON payload that works across Claude Code / Cursor / OpenCode."""
165-
return {
182+
"""Cross-host JSON payload — silent context injection + user-visible status.
183+
184+
Three keys, three audiences:
185+
186+
* ``hookSpecificOutput.additionalContext`` (Claude Code) — silent
187+
context injection into the model's window. Carries the full banner
188+
so the model knows version, collection, chunk count, audit path.
189+
* ``additional_context`` (Cursor / OpenCode forks) — snake-case
190+
duplicate; harmless on Claude Code (key ignored).
191+
* ``systemMessage`` (Claude Code) — the **user-visible** status line.
192+
Per Claude Code hooks docs, this field renders as the
193+
``SessionStart:startup says: <line>`` row the user sees in the
194+
terminal. Suppressed when ``SUPAMEM_BANNER_QUIET=1``.
195+
* ``user_message`` (Cursor) — Cursor docs note this field is
196+
"accepted but not enforced" today; we include it for forward-
197+
compat once Cursor ships UI for it. No-op on Claude Code.
198+
199+
The reason ``additionalContext`` and ``systemMessage`` carry the same
200+
payload is that both audiences benefit from the same one-liner: the
201+
user gets visible confirmation; the model gets context it can cite
202+
when answering "what version of supamem am I on?" without a tool call.
203+
"""
204+
payload: dict[str, Any] = {
166205
"hookSpecificOutput": {
167206
"hookEventName": "SessionStart",
168207
"additionalContext": banner,
169208
},
170-
# Snake-case duplicate for Cursor / OpenCode forks that adopted the
171-
# older shape. Harmless on Claude Code (key ignored).
172209
"additional_context": banner,
173210
}
211+
if os.environ.get("SUPAMEM_BANNER_QUIET", "").strip() != "1":
212+
payload["systemMessage"] = banner
213+
# Cursor future-compat (currently a no-op in Cursor's UI per its docs).
214+
payload["user_message"] = banner
215+
return payload
174216

175217

176218
def run(client: str | None = None, *, config: ResolvedConfig | None = None) -> int:

tests/test_session_start.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,20 @@ def test_build_banner_starts_with_emoji_and_version() -> None:
3636

3737

3838
def test_build_banner_health_flag_ok_when_project_collection() -> None:
39-
"""Custom collection name + reachable qdrant → ✓."""
39+
"""Custom collection + reachable qdrant + no drift → ✓.
40+
41+
`_has_install_drift` is mocked so the test's verdict doesn't depend on
42+
the developer's real CLAUDE.md / .cursor managed-block versions on the
43+
machine running the suite.
44+
"""
4045
cfg = ResolvedConfig(collection="proj-coll")
4146
with patch(
4247
"supamem.hooks.session_start._probe_collection",
4348
return_value=("proj-coll", 5),
4449
), patch(
4550
"supamem.hooks.session_start._probe_update_hint", return_value=None
51+
), patch(
52+
"supamem.hooks.session_start._has_install_drift", return_value=False
4653
):
4754
banner = build_banner(cfg)
4855
assert "🧠 supamem ✓ v" in banner
@@ -61,6 +68,22 @@ def test_build_banner_health_flag_warn_on_default_collection() -> None:
6168
assert "🧠 supamem ⚠ v" in banner
6269

6370

71+
def test_build_banner_health_flag_warn_on_install_drift() -> None:
72+
"""A client whose managed-block version differs from the running CLI
73+
flips the health flag to ⚠ — surfaces ``supamem doctor`` drift in-band."""
74+
cfg = ResolvedConfig(collection="proj-coll")
75+
with patch(
76+
"supamem.hooks.session_start._probe_collection",
77+
return_value=("proj-coll", 5),
78+
), patch(
79+
"supamem.hooks.session_start._probe_update_hint", return_value=None
80+
), patch(
81+
"supamem.hooks.session_start._has_install_drift", return_value=True
82+
):
83+
banner = build_banner(cfg)
84+
assert "🧠 supamem ⚠ v" in banner
85+
86+
6487
def test_build_banner_health_flag_warn_on_qdrant_unreachable() -> None:
6588
cfg = ResolvedConfig(collection="proj-coll")
6689
with patch(
@@ -169,13 +192,35 @@ def test_detect_client_default_when_no_env(monkeypatch: pytest.MonkeyPatch) -> N
169192
# ── _emit_payload (dual-format JSON) ────────────────────────────────────────
170193

171194

172-
def test_emit_payload_has_both_camelcase_and_snakecase_keys() -> None:
195+
def test_emit_payload_has_both_camelcase_and_snakecase_keys(
196+
monkeypatch: pytest.MonkeyPatch,
197+
) -> None:
198+
monkeypatch.delenv("SUPAMEM_BANNER_QUIET", raising=False)
173199
payload = _emit_payload("hello banner")
174-
# Camel for Claude Code
200+
# Camel for Claude Code (silent context injection)
175201
assert payload["hookSpecificOutput"]["hookEventName"] == "SessionStart"
176202
assert payload["hookSpecificOutput"]["additionalContext"] == "hello banner"
177-
# Snake for Cursor / OpenCode forks
203+
# Snake for Cursor / OpenCode forks (silent context injection)
204+
assert payload["additional_context"] == "hello banner"
205+
# User-visible status (Claude Code renders systemMessage as the
206+
# `SessionStart:startup says: <line>` row in the terminal).
207+
assert payload["systemMessage"] == "hello banner"
208+
# Cursor forward-compat (Cursor docs note user_message is "accepted but
209+
# not enforced" today; harmless on Claude Code).
210+
assert payload["user_message"] == "hello banner"
211+
212+
213+
def test_emit_payload_quiet_env_suppresses_user_visible_keys(
214+
monkeypatch: pytest.MonkeyPatch,
215+
) -> None:
216+
"""SUPAMEM_BANNER_QUIET=1 → payload still injects context silently but
217+
does NOT emit the user-visible systemMessage / user_message keys."""
218+
monkeypatch.setenv("SUPAMEM_BANNER_QUIET", "1")
219+
payload = _emit_payload("hello banner")
220+
assert payload["hookSpecificOutput"]["additionalContext"] == "hello banner"
178221
assert payload["additional_context"] == "hello banner"
222+
assert "systemMessage" not in payload
223+
assert "user_message" not in payload
179224

180225

181226
# ── run (end-to-end) ────────────────────────────────────────────────────────

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)