|
| 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