Files
phernandez 4b6de80faa fix(plugins): hooks fall back to uvx/uv when no CLI binary is on PATH
Codex flagged (review 4397232451) that the hooks locate the CLI via
`command -v basic-memory || command -v bm`, but our own README recommends
connecting the MCP server as `claude mcp add basic-memory -- uvx basic-memory mcp`
— an ephemeral uv run that leaves NO binary on PATH. In that (recommended) setup
both hooks exited before doing anything, so the bridge silently did nothing even
though the MCP server worked.

Both hooks now resolve the CLI invocation as: prefer a `basic-memory`/`bm` binary
(fast), else fall back to `uvx basic-memory`, else `uv tool run basic-memory`, else
silent no-op. The launcher may be multi-token, so the embedded Python splits it with
shlex and prepends it to each command list. The uv cache is already warm from
running the MCP server, so the fallback is cheap.

Verified: with no binary on PATH but uvx present, SessionStart now produces a brief
(previously a silent no-op). Updated the README requirement to note uvx-only works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00

233 lines
8.2 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# PreCompact hook — checkpoint the session to Basic Memory before compaction.
#
# This is the write side of the memory bridge: right before Claude Code compacts
# the context window (and the texture of the session is about to be lost), we
# write a durable SessionNote to the graph so the next session can resume from it.
#
# Phase 1 is the *extractive* cut (see DESIGN.md): we lift the opening request and
# the most recent turns straight from the transcript — no LLM call. Verified (Q2)
# that PreCompact has a ~600s budget, so a real LLM-summarized checkpoint is the
# planned "enrich later" upgrade; extractive is the safe, fast first version.
#
# Contract: advisory, never blocks compaction. Every failure path exits 0. We only
# write when a primaryProject is configured — we never write to a user's default
# graph unless they've explicitly pointed the plugin at a project.
set -u
input="$(cat 2>/dev/null || true)"
# Resolve how to invoke the Basic Memory CLI — prefer a binary on PATH, fall back to
# uvx / uv so checkpointing still works when BM was connected only as an ephemeral
# `uvx basic-memory mcp` server (no persistent CLI). Silent no-op if none available.
if command -v basic-memory >/dev/null 2>&1; then
BM="basic-memory"
elif command -v bm >/dev/null 2>&1; then
BM="bm"
elif command -v uvx >/dev/null 2>&1; then
BM="uvx basic-memory"
elif command -v uv >/dev/null 2>&1; then
BM="uv tool run basic-memory"
else
exit 0
fi
BM_HOOK_INPUT="$input" BM_BIN="$BM" python3 <<'PY' 2>/dev/null || exit 0
import json
import os
import re
import shlex
import subprocess
import sys
from datetime import datetime
# May be a single binary ("basic-memory") or a multi-token launcher
# ("uvx basic-memory"); split so it prepends cleanly onto the write command.
bm_cmd = shlex.split(os.environ.get("BM_BIN") or "basic-memory")
# A project ref can be a workspace-qualified name (route via --project) or an
# external_id UUID (route via --project-id) — names collide across workspaces, so
# bare names won't route. Mirror session-start.sh's detection.
UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
)
try:
payload = json.loads(os.environ.get("BM_HOOK_INPUT") or "{}")
except Exception:
payload = {}
cwd = payload.get("cwd") or os.getcwd()
transcript_path = payload.get("transcript_path") or ""
session_id = payload.get("session_id") or ""
def load_settings(directory):
merged = {}
for name in ("settings.json", "settings.local.json"):
path = os.path.join(directory, ".claude", name)
try:
with open(path) as fh:
merged.update(json.load(fh).get("basicMemory") or {})
except FileNotFoundError:
continue
except Exception:
continue
return merged
cfg = load_settings(cwd)
primary_project = (cfg.get("primaryProject") or "").strip()
capture_folder = (cfg.get("captureFolder") or "sessions").strip()
# Trigger: no project pinned for this Claude Code project.
# Why: a checkpoint must land somewhere intentional. Writing to the default graph
# on every compaction would pollute it without consent.
# Outcome: silent no-op until the user sets basicMemory.primaryProject.
if not primary_project:
sys.exit(0)
# --- Extract conversation text from the transcript (JSONL) ---
# The transcript is one JSON object per line. Schemas vary across Claude Code
# versions, so we probe a few shapes defensively rather than assume one.
def text_of(content):
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
t = block.get("text")
if isinstance(t, str):
parts.append(t)
return "\n".join(parts)
return ""
def turns(path):
collected = []
try:
with open(path) as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except Exception:
continue
# Skip injected/meta frames and tool results — only real human
# input and assistant prose count. Claude Code marks tool results
# with a `toolUseResult` field and injected/meta turns (command
# wrappers, system reminders, auto-continuations) with `isMeta`.
# Filtering on those flags — not a "<" content prefix — avoids both
# dropping legitimate messages that start with "<" and capturing
# tool-result noise.
if obj.get("isMeta") or obj.get("toolUseResult") is not None:
continue
msg = obj.get("message") if isinstance(obj.get("message"), dict) else obj
role = msg.get("role") or obj.get("type")
if role not in ("user", "assistant"):
continue
text = text_of(msg.get("content")).strip()
if not text:
continue
collected.append((role, text))
except Exception:
return []
return collected
conversation = turns(transcript_path)
# Trigger: nothing usable in the transcript, or no real human turn in it.
# Why: an empty or human-less checkpoint is worse than none — it would write a
# note with a dangling title and no opening request. Require a user turn.
# Outcome: silent no-op.
if not conversation or not any(role == "user" for role, _ in conversation):
sys.exit(0)
user_msgs = [t for r, t in conversation if r == "user"]
opening = user_msgs[0] if user_msgs else ""
recent_user = user_msgs[-3:]
def clip(s, n):
s = " ".join(s.split())
return s if len(s) <= n else s[: n - 1].rstrip() + "…"
# --- Build a schema-conforming SessionNote ---
# Frontmatter carries type/status/started so structured recall (SessionStart) can
# find it with metadata filters. BM merges a leading frontmatter block from the
# content into the note's frontmatter (verified empirically).
now = datetime.now()
iso = now.strftime("%Y-%m-%dT%H:%M")
# Second precision keeps the title — and therefore the note's permalink — unique
# across rapid compactions within the same minute (otherwise the second write
# would collide with the first and be dropped or overwrite it).
title = f"Session {now.strftime('%Y-%m-%d %H:%M:%S')} — {clip(opening, 40)}"
frontmatter = [
"---",
"type: session",
"status: open",
f"started: {iso}",
f"ended: {iso}",
f"project: {primary_project}",
f"cwd: {cwd}",
]
if session_id:
frontmatter.append(f"claude_session_id: {session_id}")
frontmatter += ["capture: extractive", "---"]
body = [
"",
f"# {title}",
"",
"_Automatic pre-compaction checkpoint (extractive). Full detail lives in the "
"session transcript; this note captures the thread so the next session can "
"resume._",
"",
"## Summary",
f"Working in `{cwd}`.",
f"- Opening request: {clip(opening, 300)}" if opening else "",
"",
"## Recent thread",
]
body += [f"- {clip(m, 200)}" for m in recent_user] or ["- (no recent user messages captured)"]
body += [
"",
"## Observations",
f"- [context] Session opened with: {clip(opening, 200)}" if opening else "- [context] Session checkpointed before compaction",
"- [next_step] Review this checkpoint and continue where the thread left off",
]
content = "\n".join(frontmatter + body)
# --- Write the checkpoint (best-effort) ---
# A UUID primaryProject must route via --project-id, not --project, or the write
# silently fails to land in a UUID-configured project.
project_flag = "--project-id" if UUID_RE.match(primary_project) else "--project"
try:
subprocess.run(
[
*bm_cmd, "tool", "write-note",
"--title", title,
"--folder", capture_folder,
project_flag, primary_project,
"--tags", "session",
"--tags", "auto-capture",
],
input=content,
capture_output=True,
text=True,
timeout=60,
)
except Exception:
sys.exit(0)
PY