mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
4b6de80faa
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>
233 lines
8.2 KiB
Bash
Executable File
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
|