Skip to content

Commit e5425bb

Browse files
dzmitrys-devclaude
andcommitted
release: v0.1.4 — SessionStart banner + supamem live dashboard
Visibility round. The auto-injection PreToolUse hook is silent by design (that's how it saves tokens), so users had no visible evidence supamem was running. v0.1.4 adds two observability surfaces without changing retrieval behavior. Added: - supamem hook session-start: one-line plain-text banner injected via additionalContext at Claude Code / Cursor / OpenCode session start. Format: 🧠 supamem v0.1.4 · <collection> · <N> chunks · audit <path>. Cross-client portability via dual camelCase + snake_case JSON keys. Auto-detects calling client from CLAUDECODE / OPENCODE / CURSOR_AGENT env vars. Fail-soft — never blocks session start. - supamem live: Rich-Live dashboard tailing audit.jsonl in real time. Run in a side terminal for instant visibility into every retrieval call. Uses watchfiles.awatch with poll fallback; pipe-safe (plain JSONL when stdout isn't a TTY); handles rotation, resize, Ctrl-C. Deps: - watchfiles>=0.24 (Rust-backed async file watcher; has prebuilt wheels) Design call: per-injection footer in PreToolUse additionalContext was dropped — chat hosts don't render Markdown <details>, so the "collapsible" pattern would just add ~80 tokens to every Edit (+20%) for no UI benefit. Banner + dashboard provide visibility without per-Edit token tax. 233/233 tests green (25 new); ruff clean; twine check PASSED; smoke verified against local Qdrant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 05f2816 commit e5425bb

10 files changed

Lines changed: 869 additions & 7 deletions

File tree

CHANGELOG.md

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

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

5+
## v0.1.4 — 2026-04-29
6+
7+
Visibility round: gives users observable evidence that supamem is alive
8+
and working. Three additions; no behavior change to retrieval.
9+
10+
### Added
11+
12+
- **SessionStart banner** (`supamem hook session-start`): one-line plain-text
13+
status injected at session start in Claude Code / Cursor / OpenCode via
14+
`additionalContext`. Format:
15+
`🧠 supamem v0.1.4 · <collection> · <N> chunks · audit <path>`
16+
Cross-client portability via dual JSON keys (`hookSpecificOutput.additionalContext`
17+
+ snake-case `additional_context`). Auto-detects calling client from env
18+
vars (`CLAUDECODE`, `OPENCODE`, `CURSOR_AGENT`) when `--client` is omitted.
19+
Fail-soft per hook discipline — never raises, never blocks session start.
20+
- **`supamem live` CLI**: Rich-Live terminal dashboard tailing the audit
21+
JSONL in real time. Run in a side terminal alongside Claude Code /
22+
Cursor / OpenCode for instant visibility into the silent PreToolUse-hook
23+
injections (which save tokens by NOT showing UI). Uses
24+
`watchfiles.awatch` for OS-native file change notifications; falls back
25+
to polling if `watchfiles` isn't available. Pipe-safe: prints plain JSONL
26+
when stdout isn't a TTY. Handles file rotation, terminal resize, and
27+
Ctrl-C cleanly.
28+
29+
### Added (deps)
30+
31+
- `watchfiles>=0.24` — Rust-backed async file watcher for `supamem live`.
32+
Has manylinux + macOS arm64 wheels, no source build required at install.
33+
34+
### Design notes
35+
36+
- A per-injection footer in PreToolUse `additionalContext` was considered
37+
but **dropped**: chat hosts don't render Markdown `<details>`, so the
38+
"collapsible" pattern is fiction. A footer would just add ~80 tokens to
39+
every Edit (+20%) for no UI benefit. The banner + dashboard provide
40+
visibility without the per-Edit token tax.
41+
542
## v0.1.3 — 2026-04-29
643

744
Adds the missing **write path** so supamem can serve as the *only* memory

pyproject.toml

Lines changed: 2 additions & 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.1.3"
7+
version = "0.1.4"
88
description = "Project-agnostic dual-memory tooling for Claude Code, Cursor, and opencode"
99
readme = "README.md"
1010
license = "MIT"
@@ -22,6 +22,7 @@ dependencies = [
2222
"platformdirs>=4.2",
2323
"packaging>=23.0",
2424
"PyYAML>=6.0",
25+
"watchfiles>=0.24",
2526
]
2627

2728
[project.urls]

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.1.3"
2+
__version__ = "0.1.4"

src/supamem/cli.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,27 @@ class StatsFormat(str, Enum):
130130
json = "json"
131131

132132

133+
@app.command("live")
134+
def cmd_live(
135+
audit_path: Optional[str] = typer.Option(
136+
None,
137+
"--audit-path",
138+
help="Override audit JSONL path (default: $XDG_CACHE_HOME/supamem/audit.jsonl).",
139+
),
140+
) -> None:
141+
"""🧠 Live dashboard tailing the audit JSONL — watch every retrieval call.
142+
143+
Run this in a side terminal alongside Claude Code / Cursor / OpenCode
144+
for real-time visibility into the silent PreToolUse-hook injections.
145+
Pipe-safe: prints plain JSONL when stdout is not a TTY.
146+
"""
147+
from pathlib import Path
148+
149+
from supamem.live import run_live
150+
151+
raise typer.Exit(run_live(Path(audit_path) if audit_path else None))
152+
153+
133154
@app.command("stats")
134155
def cmd_stats(
135156
show: StatsWindow = typer.Option(StatsWindow.today, "--show", help="Time window."),

src/supamem/hooks/__init__.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Per-client hooks for supamem.
22
3-
The dispatcher routes ``supamem hook <client>`` to the right module:
4-
- ``claude-code`` → :mod:`supamem.hooks.claude_code`
5-
- ``opencode`` → :mod:`supamem.hooks.opencode`
6-
- ``cursor`` → snapshot regen lives in :mod:`supamem.hooks.cursor`
3+
The dispatcher routes ``supamem hook <name>`` to the right module:
4+
- ``claude-code`` / ``opencode`` → PreToolUse(Edit|Write) memory injection
5+
- ``cursor`` → snapshot regen
6+
- ``session-start`` (v0.1.4+) → cross-client SessionStart banner
77
"""
88
from __future__ import annotations
99

@@ -28,4 +28,8 @@ def dispatch(
2828
from supamem.hooks.opencode import run
2929

3030
return run(file_path or Path("."), config)
31+
if client == "session-start":
32+
from supamem.hooks.session_start import run
33+
34+
return run(client=None, config=config)
3135
raise ValueError(f"supamem: unknown hook client: {client!r}")

src/supamem/hooks/session_start.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""SessionStart banner — emits a one-line status string when an AI client
2+
opens a new session in a supamem-enabled project.
3+
4+
Visibility design (v0.1.4):
5+
- Plain text, ≤200 chars (chat hosts don't render Markdown in additionalContext)
6+
- Emoji prefix for instant recognition; no badges, no `<details>`, no fluff
7+
- Cross-client portability via dual JSON keys (camelCase + snake_case) so
8+
Claude Code, Cursor, and OpenCode all pick it up
9+
- ALL probes are best-effort — banner emits a degraded line on any failure
10+
rather than blocking session start
11+
12+
Output shape (per Claude Code hook contract):
13+
{
14+
"hookSpecificOutput": {
15+
"hookEventName": "SessionStart",
16+
"additionalContext": "🧠 supamem v0.1.4 · ..."
17+
},
18+
"additional_context": "🧠 supamem v0.1.4 · ..."
19+
}
20+
21+
The duplicate snake_case key is for Cursor/OpenCode forks that adopted the
22+
older shape; harmless on Claude Code (ignored).
23+
"""
24+
from __future__ import annotations
25+
26+
import json
27+
import logging
28+
import os
29+
import sys
30+
from pathlib import Path
31+
from typing import Any
32+
33+
from supamem import __version__
34+
from supamem.config import ResolvedConfig
35+
36+
log = logging.getLogger("supamem.hooks.session_start")
37+
38+
# ── Limits ─────────────────────────────────────────────────────────────────
39+
MAX_BANNER_CHARS = 200
40+
41+
42+
# ── Probes (each best-effort; never raise) ─────────────────────────────────
43+
44+
45+
def _probe_collection(cfg: ResolvedConfig) -> tuple[str | None, int | None]:
46+
"""Return (collection_name, point_count). On any failure return (cfg.collection or None, None)."""
47+
try:
48+
from qdrant_client import QdrantClient
49+
50+
client = QdrantClient(
51+
url=cfg.qdrant_url,
52+
api_key=cfg.qdrant_api_key or None,
53+
check_compatibility=False,
54+
timeout=2,
55+
)
56+
info = client.get_collection(cfg.collection)
57+
return cfg.collection, int(info.points_count or 0)
58+
except Exception as exc: # noqa: BLE001
59+
log.debug("session_start: collection probe failed: %s", exc)
60+
return cfg.collection or None, None
61+
62+
63+
def _probe_audit_path() -> Path:
64+
"""The canonical audit JSONL path (``platformdirs.user_cache_dir/audit.jsonl``)."""
65+
try:
66+
import platformdirs
67+
68+
return Path(platformdirs.user_cache_dir("supamem")) / "audit.jsonl"
69+
except Exception:
70+
return Path.home() / ".cache" / "supamem" / "audit.jsonl"
71+
72+
73+
# ── Banner construction ─────────────────────────────────────────────────────
74+
75+
76+
def build_banner(cfg: ResolvedConfig | None = None) -> str:
77+
"""Compose the one-line banner. Pure function (cfg may be None for tests)."""
78+
cfg = cfg or ResolvedConfig()
79+
collection, points = _probe_collection(cfg)
80+
audit = _probe_audit_path()
81+
82+
parts = [f"🧠 supamem v{__version__}"]
83+
if collection:
84+
if points is None:
85+
parts.append(f"{collection} (qdrant unreachable)")
86+
else:
87+
parts.append(f"{collection} · {points} chunks")
88+
parts.append(f"audit {audit}")
89+
90+
banner = " · ".join(parts)
91+
if len(banner) > MAX_BANNER_CHARS:
92+
banner = banner[: MAX_BANNER_CHARS - 1] + "…"
93+
return banner
94+
95+
96+
# ── Cross-client emission ───────────────────────────────────────────────────
97+
98+
99+
def _detect_client() -> str:
100+
"""Sniff the calling client from env vars when --client is omitted."""
101+
if os.environ.get("CLAUDECODE", "").strip():
102+
return "claude-code"
103+
if os.environ.get("OPENCODE", "").strip():
104+
return "opencode"
105+
if os.environ.get("CURSOR_AGENT", "").strip() or os.environ.get("CURSOR", "").strip():
106+
return "cursor"
107+
return "claude-code" # safest default — Claude Code accepts the dual schema
108+
109+
110+
def _emit_payload(banner: str) -> dict[str, Any]:
111+
"""Dual-format JSON payload that works across Claude Code / Cursor / OpenCode."""
112+
return {
113+
"hookSpecificOutput": {
114+
"hookEventName": "SessionStart",
115+
"additionalContext": banner,
116+
},
117+
# Snake-case duplicate for Cursor / OpenCode forks that adopted the
118+
# older shape. Harmless on Claude Code (key ignored).
119+
"additional_context": banner,
120+
}
121+
122+
123+
def run(client: str | None = None, *, config: ResolvedConfig | None = None) -> int:
124+
"""Run the session-start hook. Returns exit code; never raises."""
125+
try:
126+
cfg = config
127+
if cfg is None:
128+
from supamem.config import load_config
129+
130+
cfg, _ = load_config()
131+
client_name = client or _detect_client()
132+
banner = build_banner(cfg)
133+
payload = _emit_payload(banner)
134+
# Discard any stdin payload — we don't need it for the banner
135+
try:
136+
if not sys.stdin.isatty():
137+
sys.stdin.read()
138+
except Exception:
139+
pass
140+
sys.stdout.write(json.dumps(payload))
141+
sys.stdout.flush()
142+
log.info("session_start: emitted banner for client=%s (%d chars)", client_name, len(banner))
143+
return 0
144+
except Exception as exc: # noqa: BLE001 — fail-soft per hook discipline
145+
log.exception("session_start failed: %s", exc)
146+
return 0 # never break session start
147+
148+
149+
__all__ = ["build_banner", "run", "MAX_BANNER_CHARS"]

0 commit comments

Comments
 (0)