#!/usr/bin/env bash # Recall Auto-Memory Hooks — Install Script # # Usage: # curl -fsSL https://recallmcp.com/install-hooks | bash # bash install.sh --api-key sk-xxx # bash install.sh --api-key sk-xxx --server-url https://recallmcp.com # # What this does: # 1. Prompts for API key (if not provided) # 2. Creates ~/.claude/recall/hooks/ with all hook scripts # 3. Creates ~/.claude/recall/config.json with your settings # 4. Patches ~/.claude/settings.json to register the hooks with Claude Code set -euo pipefail # ─── Constants ──────────────────────────────────────────────────────────────── DEFAULT_SERVER_URL="https://recallmcp.com" INSTALL_DIR="${HOME}/.claude/recall" HOOKS_DIR="${INSTALL_DIR}/hooks" CONFIG_FILE="${INSTALL_DIR}/config.json" CLAUDE_SETTINGS="${HOME}/.claude/settings.json" # Version: read from sibling VERSION file when run locally; the server injects # the real value (replacing 1.18.9) when served via curl | bash. if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != "bash" ]]; then _SCRIPT_DIR_TMP="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)" VERSION="$(cat "${_SCRIPT_DIR_TMP}/VERSION" 2>/dev/null || echo "1.18.9")" else VERSION="1.18.9" fi # ─── Colour helpers ─────────────────────────────────────────────────────────── GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' info() { echo -e "${GREEN}[recall]${NC} $*"; } warn() { echo -e "${YELLOW}[recall]${NC} $*"; } error() { echo -e "${RED}[recall]${NC} $*" >&2; } # ─── Argument parsing ───────────────────────────────────────────────────────── API_KEY="${RECALL_API_KEY:-}" SERVER_URL="${RECALL_SERVER_URL:-${DEFAULT_SERVER_URL}}" while [[ $# -gt 0 ]]; do case "$1" in --api-key) API_KEY="$2"; shift 2 ;; --server-url) SERVER_URL="$2"; shift 2 ;; *) error "Unknown argument: $1"; exit 1 ;; esac done # ─── Prompt for API key if not provided ─────────────────────────────────────── if [[ -z "${API_KEY}" ]]; then if [[ -t 0 ]]; then # Interactive terminal — prompt for input echo "" echo "Enter your Recall API key (from https://recallmcp.com/dashboard):" read -r -s API_KEY echo "" else # Piped from curl — cannot prompt interactively error "API key is required when piping from curl." error "Usage: curl -fsSL https://recallmcp.com/install-hooks | bash -s -- --api-key sk-xxx" error " Or: RECALL_API_KEY=sk-xxx curl -fsSL https://recallmcp.com/install-hooks | bash" exit 1 fi fi if [[ -z "${API_KEY}" ]]; then error "API key is required. Get one at https://recallmcp.com/dashboard" exit 1 fi # ─── Locate source scripts ──────────────────────────────────────────────────── # When run via curl | bash, BASH_SOURCE[0] is empty — detect from env or use # a temp download approach. When run as a local script, use the script's dir. if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != "bash" ]]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" else # Running via curl | bash — scripts are embedded in this file (see below) SCRIPT_DIR="" fi # ─── Create directory structure ─────────────────────────────────────────────── info "Creating ${HOOKS_DIR}/lib/ ..." mkdir -p "${HOOKS_DIR}/lib" # ─── Write hook scripts ─────────────────────────────────────────────────────── write_hook_from_file() { local dest="$1" local src_name="$2" cp "${SCRIPT_DIR}/${src_name}" "${dest}" chmod +x "${dest}" } write_embedded_hooks() { # When run via curl | bash, write hooks from embedded heredocs cat > "${HOOKS_DIR}/lib/config.sh" <<'ENDOFHOOK' #!/usr/bin/env bash # Recall config reader — shared by all hook scripts. # Reads ~/.claude/recall/config.json (or the file RECALL_CONFIG_FILE names, for a test or a second account); # config.json takes priority over env vars. # Exports: RECALL_API_KEY, RECALL_SERVER_URL, RECALL_WORKSPACE, RECALL_GIT_REMOTE set -euo pipefail CONFIG_FILE="${RECALL_CONFIG_FILE:-${HOME}/.claude/recall/config.json}" DEFAULT_SERVER_URL="https://recallmcp.com" # Read config file — config.json takes priority over environment variables. # This prevents stale env vars (set at shell startup) from overriding a # key that was rotated/healed during the session. _cfg_api_key="" _cfg_server_url="" if [[ -f "${CONFIG_FILE}" ]]; then if command -v jq &>/dev/null; then _cfg_api_key="$(jq -r '.api_key // empty' "${CONFIG_FILE}" 2>/dev/null || true)" _cfg_server_url="$(jq -r '.server_url // empty' "${CONFIG_FILE}" 2>/dev/null || true)" elif command -v python3 &>/dev/null; then _cfg_api_key="$(python3 -c "import json,sys; d=json.load(open('${CONFIG_FILE}')); print(d.get('api_key',''))" 2>/dev/null || true)" _cfg_server_url="$(python3 -c "import json,sys; d=json.load(open('${CONFIG_FILE}')); print(d.get('server_url',''))" 2>/dev/null || true)" fi fi # config.json key takes priority; fall back to env var; then empty export RECALL_API_KEY="${_cfg_api_key:-${RECALL_API_KEY:-}}" export RECALL_SERVER_URL="${_cfg_server_url:-${RECALL_SERVER_URL:-${DEFAULT_SERVER_URL}}}" unset _cfg_api_key _cfg_server_url # The workspace is the project, not the folder the agent happens to be in. Hooks run in the # shell's current directory, which follows every `cd`, so $(pwd) alone filed memories under # src/lib/fonts, worktrees, scratchpads and /tmp as separate workspaces. Start from the # directory Claude Code was launched in and resolve it to its git checkout: the main checkout # for a worktree, the repo root for a subfolder. Outside git, the launch directory itself. _recall_base="${CLAUDE_PROJECT_DIR:-}" [[ -n "${_recall_base}" && -d "${_recall_base}" ]] || _recall_base="$(pwd)" _recall_root="" # --git-common-dir is the main checkout's .git from any worktree or subfolder; `cd` into it # makes it absolute on every git version (older ones print it relative). _recall_common="$(cd "${_recall_base}" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd || true)" if [[ "${_recall_common##*/}" == ".git" ]]; then _recall_root="${_recall_common%/.git}" else # A submodule or unusual layout: its own top level. _recall_root="$(git -C "${_recall_base}" rev-parse --show-toplevel 2>/dev/null || true)" fi RECALL_GIT_REMOTE="${RECALL_GIT_REMOTE:-$(git -C "${_recall_base}" config --get remote.origin.url 2>/dev/null || true)}" # A repo cloned with a token keeps it in its https remote; never send it on (ssh remotes carry none). RECALL_GIT_REMOTE="$(printf '%s' "${RECALL_GIT_REMOTE}" | sed -E 's#^([Hh][Tt][Tt][Pp][Ss]?://)[^/@]*@#\1#')" RECALL_WORKSPACE="${RECALL_WORKSPACE:-${_recall_root:-${_recall_base}}}" unset _recall_base _recall_root _recall_common export RECALL_GIT_REMOTE export RECALL_WORKSPACE ENDOFHOOK chmod +x "${HOOKS_DIR}/lib/config.sh" cat > "${HOOKS_DIR}/session-start.sh" <<'ENDOFHOOK' #!/usr/bin/env bash # Recall session-start hook # Fetches formatted memory context from Recall and prints to stdout. # Claude Code injects stdout from SessionStart hooks into the conversation context. # # Registered in settings.json under: # hooks.SessionStart[].hooks[].command # matcher: "startup|clear|compact" # timeout: 10 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Detect if running from a Claude Code plugin install vs curl|bash install. # Plugin: scripts live in ~/.claude/plugins/recall/scripts/ # curl|bash: scripts live in ~/.claude/recall/hooks/ IS_PLUGIN_INSTALL=false [[ "${SCRIPT_DIR}" == *"/plugins/"* ]] && IS_PLUGIN_INSTALL=true # ─── Install rules file (plugin mode only) ──────────────────────────────────── # Copies plugin/recall/rules/recall.md → ~/.claude/rules/recall.md so Claude # always has Recall's always-on directives injected into every session. if "${IS_PLUGIN_INSTALL}"; then _RULES_SRC="${SCRIPT_DIR}/../rules/recall.md" _RULES_DST="${HOME}/.claude/rules/recall.md" if [[ -f "${_RULES_SRC}" ]]; then mkdir -p "${HOME}/.claude/rules" 2>/dev/null || true cp "${_RULES_SRC}" "${_RULES_DST}" 2>/dev/null || true fi fi # --- Reconcile settings.json hook registration ------------------------------- # History: this block used to ADD every hook to ~/.claude/settings.json on the # belief that Claude Code does not fire a plugin's hooks.json. That is no # longer true (verified 2026-08-30: with settings stripped, manifest hooks # stored 159 observations in 28 minutes) - so those settings entries made # every hook fire twice and re-imposed the old noisy PostToolUse matcher on # every session start. # # Now: when a hooks manifest exists next to this script tree, Claude Code # fires the hooks natively - REMOVE any legacy settings.json entries so each # hook fires exactly once. Only a tree with no manifest (curl|bash # standalone install) still needs settings registration, with the current # matchers. if command -v python3 &>/dev/null; then _MANIFEST="${SCRIPT_DIR}/../hooks/hooks.json" if [[ -f "${_MANIFEST}" ]]; then python3 - <<'PYEOF' 2>/dev/null || true import json, os settings_path = os.path.expanduser("~/.claude/settings.json") try: with open(settings_path) as f: settings = json.load(f) except Exception: settings = None if settings is not None and isinstance(settings.get("hooks"), dict): marker = "/plugins/recall/scripts/" changed = False hooks = settings["hooks"] for event in list(hooks.keys()): if not isinstance(hooks[event], list): continue kept = [ e for e in hooks[event] if not any(marker in h.get("command", "") for h in e.get("hooks", [])) ] if len(kept) != len(hooks[event]): hooks[event] = kept changed = True if not hooks[event]: del hooks[event] if changed: with open(settings_path, "w") as f: json.dump(settings, f, indent=2) PYEOF elif "${IS_PLUGIN_INSTALL}"; then python3 - <<'PYEOF' 2>/dev/null || true import json, os settings_path = os.path.expanduser("~/.claude/settings.json") try: with open(settings_path) as f: settings = json.load(f) except Exception: settings = {} for event in ("PostToolUse", "PostToolUseFailure", "SessionStart", "SessionEnd", "PreCompact", "Stop"): settings.setdefault("hooks", {}).setdefault(event, []) base = 'bash "${HOME}/.claude/plugins/recall/scripts/' def present(event, cmd): return any( any(h.get("command", "") == cmd for h in e.get("hooks", [])) for e in settings["hooks"][event] ) changed = False # observe.sh captures only failing Bash commands since v1.17.0. A failing command fires PostToolUseFailure, # not PostToolUse (Claude Code 2.1.288), so it listens on both. OBSERVE_MATCHER = "Bash" observe_cmd = base + 'observe.sh"' for event in ("PostToolUse", "PostToolUseFailure"): observe_ok = any( e.get("matcher", "") == OBSERVE_MATCHER and any(h.get("command", "") == observe_cmd for h in e.get("hooks", [])) for e in settings["hooks"][event] ) if not observe_ok: settings["hooks"][event] = [ e for e in settings["hooks"][event] if not any(h.get("command", "") == observe_cmd for h in e.get("hooks", [])) ] settings["hooks"][event].append({ "matcher": OBSERVE_MATCHER, "hooks": [{"type": "command", "command": observe_cmd, "async": True, "timeout": 10}] }) changed = True if not present("SessionStart", base + 'session-start.sh"'): settings["hooks"]["SessionStart"].append({ "hooks": [{"type": "command", "command": base + 'session-start.sh"', "timeout": 15}] }) changed = True if not present("SessionStart", base + 'compact-restore.sh"'): settings["hooks"]["SessionStart"].append({ "matcher": "compact", "hooks": [{"type": "command", "command": base + 'compact-restore.sh"', "timeout": 5}] }) changed = True if not present("PreCompact", base + 'pre-compact.sh"'): settings["hooks"]["PreCompact"].append({ "hooks": [{"type": "command", "command": base + 'pre-compact.sh"', "timeout": 15}] }) changed = True if not present("Stop", base + 'session-end.sh"'): settings["hooks"]["Stop"].append({ "hooks": [{"type": "command", "command": base + 'session-end.sh"', "async": True, "timeout": 10}] }) changed = True # stop-summarize.sh fires once per session on SessionEnd since v1.17.0; # migrate any old per-turn Stop registration. summarize_cmd = base + 'stop-summarize.sh"' old_stop = [ e for e in settings["hooks"]["Stop"] if any(h.get("command", "") == summarize_cmd for h in e.get("hooks", [])) ] if old_stop: settings["hooks"]["Stop"] = [e for e in settings["hooks"]["Stop"] if e not in old_stop] changed = True if not present("SessionEnd", summarize_cmd): settings["hooks"]["SessionEnd"].append({ "hooks": [{"type": "command", "command": summarize_cmd, "async": True, "timeout": 10}] }) changed = True if not present("Stop", base + 'stop.sh"'): settings["hooks"]["Stop"].append({ "hooks": [{"type": "command", "command": base + 'stop.sh"', "timeout": 5}] }) changed = True for event in list(settings["hooks"].keys()): if not settings["hooks"][event]: del settings["hooks"][event] if changed: with open(settings_path, "w") as f: json.dump(settings, f, indent=2) PYEOF fi fi # Load config — silently exit if something goes wrong # shellcheck source=lib/config.sh source "${SCRIPT_DIR}/lib/config.sh" || exit 0 # No API key — nothing to do [[ -z "${RECALL_API_KEY}" ]] && exit 0 # ─── Auto-heal revoked API key ──────────────────────────────────────────────── # The dashboard used to silently rotate keys, leaving config.json stale with a # revoked key. Every hook call would fail with 401. This block detects that and # automatically syncs the active key from ~/.claude.json (MCP config) so hooks # resume working without any user intervention. _DEBUG_LOG="${HOME}/.claude/recall/debug.log" _heal_status="$(curl --silent --max-time 5 --output /dev/null \ --write-out "%{http_code}" \ --request GET \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ "${RECALL_SERVER_URL}/api/memories?limit=1" 2>/dev/null || echo "000")" if [[ "${_heal_status}" == "401" ]]; then _new_key="" _claude_json="${HOME}/.claude.json" # Scan ~/.claude.json for any MCP server with "recall" in the name that has # a Bearer token in its headers — that's the active key for this account. if [[ -f "${_claude_json}" ]]; then if command -v python3 &>/dev/null; then _new_key="$(python3 - <<'HEAL_PY' 2>/dev/null || true import json, os try: d = json.load(open(os.path.expanduser("~/.claude.json"))) for name, srv in d.get("mcpServers", {}).items(): if "recall" in name.lower(): auth = srv.get("headers", {}).get("Authorization", "") if auth.startswith("Bearer sk-"): print(auth.split(" ", 1)[1]) break except Exception: pass HEAL_PY )" elif command -v jq &>/dev/null; then _new_key="$(jq -r ' .mcpServers | to_entries[] | select(.key | ascii_downcase | contains("recall")) | .value.headers.Authorization // empty ' "${_claude_json}" 2>/dev/null | sed 's/^Bearer //' | head -1 || true)" fi fi if [[ -n "${_new_key}" && "${_new_key}" != "${RECALL_API_KEY}" ]]; then # Verify the candidate key is actually valid before saving _verify_status="$(curl --silent --max-time 5 --output /dev/null \ --write-out "%{http_code}" \ --request GET \ --header "Authorization: Bearer ${_new_key}" \ "${RECALL_SERVER_URL}/api/memories?limit=1" 2>/dev/null || echo "000")" if [[ "${_verify_status}" != "401" ]]; then _config_file="${HOME}/.claude/recall/config.json" RECALL_NEW_KEY="${_new_key}" python3 - <<'UPDATE_PY' 2>/dev/null || true import json, os cfg_path = os.path.expanduser("~/.claude/recall/config.json") try: cfg = json.load(open(cfg_path)) except Exception: cfg = {} cfg["api_key"] = os.environ["RECALL_NEW_KEY"] json.dump(cfg, open(cfg_path, "w"), indent=2) UPDATE_PY # Update in-memory key so the rest of this session uses the healed key export RECALL_API_KEY="${_new_key}" echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [session-start] auto-healed: revoked key replaced from .claude.json" \ >> "${_DEBUG_LOG}" 2>/dev/null || true else echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [session-start] warning: key revoked, candidate from .claude.json also invalid — hooks disabled" \ >> "${_DEBUG_LOG}" 2>/dev/null || true fi else echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [session-start] warning: key revoked, no replacement found in .claude.json — hooks disabled" \ >> "${_DEBUG_LOG}" 2>/dev/null || true fi fi # ─── Pure-bash semver comparison: returns 0 if $1 is strictly newer than $2 ── _recall_version_newer() { local new_ver="$1" cur_ver="$2" [[ "${new_ver}" == "${cur_ver}" ]] && return 1 local IFS='.' local -a na=($new_ver) ca=($cur_ver) for i in 0 1 2; do local n="${na[$i]:-0}" c="${ca[$i]:-0}" n="${n//[^0-9]/}"; n="${n:-0}" c="${c//[^0-9]/}"; c="${c:-0}" (( n > c )) && return 0 (( n < c )) && return 1 done return 1 } # ─── Read installed version from config.json ────────────────────────────────── CONFIG_FILE="${HOME}/.claude/recall/config.json" INSTALLED_VERSION="1.0.0" if [[ -f "${CONFIG_FILE}" ]]; then if command -v jq &>/dev/null; then INSTALLED_VERSION="$(jq -r '.version // empty' "${CONFIG_FILE}" 2>/dev/null || true)" elif command -v python3 &>/dev/null; then INSTALLED_VERSION="$(python3 -c "import json; d=json.load(open('${CONFIG_FILE}')); print(d.get('version',''))" 2>/dev/null || true)" fi fi INSTALLED_VERSION="${INSTALLED_VERSION:-1.0.0}" # For plugin installs, plugin.json is the ground truth for the installed version. # config.json may not exist (user never ran install.sh) so it would default to 1.0.0. # Self-heal: if the running script is newer than plugin.json (e.g. background update # downloaded new scripts but plugin.json write failed), update plugin.json immediately # so version detection is always accurate. SCRIPT_VERSION must match every release. SCRIPT_VERSION="1.18.9" if "${IS_PLUGIN_INSTALL}"; then _PLUGIN_JSON="${SCRIPT_DIR}/../.claude-plugin/plugin.json" if [[ -f "${_PLUGIN_JSON}" ]]; then if command -v python3 &>/dev/null; then _pv="$(python3 -c "import json; d=json.load(open('${_PLUGIN_JSON}')); print(d.get('version',''))" 2>/dev/null || true)" elif command -v jq &>/dev/null; then _pv="$(jq -r '.version // empty' "${_PLUGIN_JSON}" 2>/dev/null || true)" fi [[ -n "${_pv}" ]] && INSTALLED_VERSION="${_pv}" # Self-heal: update plugin.json if the running script is newer than what it records if _recall_version_newer "${SCRIPT_VERSION}" "${INSTALLED_VERSION}"; then if command -v python3 &>/dev/null; then _NV="${SCRIPT_VERSION}" _PJ="${_PLUGIN_JSON}" python3 -c " import json, os pj = os.environ['_PJ']; v = os.environ['_NV'] try: d = json.load(open(pj)); d['version'] = v; json.dump(d, open(pj, 'w'), indent=2) except Exception: pass " 2>/dev/null || true elif command -v jq &>/dev/null; then _tmp="$(jq --arg v "${SCRIPT_VERSION}" '.version = $v' "${_PLUGIN_JSON}" 2>/dev/null || true)" [[ -n "${_tmp}" ]] && echo "${_tmp}" > "${_PLUGIN_JSON}" || true fi INSTALLED_VERSION="${SCRIPT_VERSION}" fi fi fi # ─── Keep the standalone tree in step with this plugin ─────────────────────── # A settings statusLine set up before the marketplace plugin runs ~/.claude/plugins/recall/scripts/statusline.sh, # a separate tree that nothing else updates once the plugin itself is current (the background update below runs # only when the server has a newer version). An older tree there would keep printing the Recall segment where the # mod already draws Recall's line. So whenever that tree is older than this plugin, copy this plugin's scripts into # it, in the background. Only plugin installs do this, and only into that one folder. _STANDALONE_TREE="${HOME}/.claude/plugins/recall" if "${IS_PLUGIN_INSTALL}" && [[ -d "${_STANDALONE_TREE}/scripts" ]] && [[ "${_STANDALONE_TREE}/scripts" != "${SCRIPT_DIR}" ]]; then _tree_version="" if command -v python3 &>/dev/null; then # The path goes in through the environment, never into the code, so a HOME with a quote in it cannot change it. _tree_version="$(_PJ="${_STANDALONE_TREE}/.claude-plugin/plugin.json" python3 -c "import json, os; print(json.load(open(os.environ['_PJ'])).get('version',''))" 2>/dev/null || true)" elif command -v jq &>/dev/null; then _tree_version="$(jq -r '.version // empty' "${_STANDALONE_TREE}/.claude-plugin/plugin.json" 2>/dev/null || true)" fi # Never downgrade: only a tree older than this plugin is touched. if _recall_version_newer "${SCRIPT_VERSION}" "${_tree_version:-0.0.0}"; then ( # Each file is written next to its target and renamed over it, so a status line that runs mid-copy reads # either the old file or the new one, never half of one. plugin.json goes last. _put() { cp "$1" "$2.tmp.$$" 2>/dev/null && chmod +x "$2.tmp.$$" 2>/dev/null && mv -f "$2.tmp.$$" "$2" 2>/dev/null || rm -f "$2.tmp.$$"; } for _sf in session-start.sh observe.sh statusline.sh stop.sh stop-summarize.sh pre-compact.sh compact-restore.sh session-end.sh; do [[ -f "${SCRIPT_DIR}/${_sf}" ]] && _put "${SCRIPT_DIR}/${_sf}" "${_STANDALONE_TREE}/scripts/${_sf}" done if [[ -f "${SCRIPT_DIR}/lib/config.sh" ]]; then mkdir -p "${_STANDALONE_TREE}/scripts/lib" 2>/dev/null || true _put "${SCRIPT_DIR}/lib/config.sh" "${_STANDALONE_TREE}/scripts/lib/config.sh" fi if [[ -f "${_STANDALONE_TREE}/.claude-plugin/plugin.json" ]] && command -v python3 &>/dev/null; then _NV="${SCRIPT_VERSION}" _PJ="${_STANDALONE_TREE}/.claude-plugin/plugin.json" python3 -c " import json, os pj = os.environ['_PJ']; v = os.environ['_NV']; tmp = pj + '.tmp.' + str(os.getpid()) try: d = json.load(open(pj)); d['version'] = v with open(tmp, 'w') as f: json.dump(d, f, indent=2) os.replace(tmp, pj) except Exception: try: os.remove(tmp) except OSError: pass " 2>/dev/null || true fi ) >/dev/null 2>&1 & disown $! 2>/dev/null || true fi fi # ─── State file (shared with observe.sh) ───────────────────────────────────── STATE_FILE="${HOME}/.claude/recall/state.json" # Heartbeats of Recall's Claude Code mod (hooks/register.js), one per session: a day after the last beat the # session is long gone, so the file goes too. find "${HOME}/.claude/recall" -maxdepth 1 -name 'mod-heartbeat-*' -type f -mtime +1 -delete 2>/dev/null || true # ─── Update check (every session, synchronous, 2s timeout) ─────────────────── # Runs on every session start so updates are picked up immediately after deploy. # The 2s max-time is the only throttle needed — if the server is unreachable the # check is a no-op and the session continues normally. NOW_EPOCH="$(date +%s 2>/dev/null || echo 0)" REMOTE_VERSION="" REMOTE_CHANGELOG="" if true; then REMOTE_JSON="$(curl --silent --max-time 2 --fail "${RECALL_SERVER_URL}/hooks/version?since=${INSTALLED_VERSION}" 2>/dev/null || true)" # Parse version and changelog only when we got a response if [[ -n "${REMOTE_JSON}" ]]; then if command -v python3 &>/dev/null; then REMOTE_VERSION="$(echo "${REMOTE_JSON}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('data',{}).get('version',''))" 2>/dev/null || true)" REMOTE_CHANGELOG="$(echo "${REMOTE_JSON}" | python3 -c " import json, sys d = json.load(sys.stdin) lines = d.get('data', {}).get('changelog', []) print('\n'.join(' • ' + l for l in lines)) " 2>/dev/null || true)" elif command -v jq &>/dev/null; then REMOTE_VERSION="$(echo "${REMOTE_JSON}" | jq -r '.data.version // empty' 2>/dev/null || true)" REMOTE_CHANGELOG="$(echo "${REMOTE_JSON}" | jq -r '(.data.changelog // []) | map(" • " + .) | join("\n")' 2>/dev/null || true)" fi fi # Always write last_update_check (even on curl failure) so next session skips the 2s check. # Only touch available_version when REMOTE_VERSION is non-empty (curl succeeded). if command -v python3 &>/dev/null; then REMOTE_VERSION="${REMOTE_VERSION}" INSTALLED_VERSION="${INSTALLED_VERSION}" NOW_EPOCH="${NOW_EPOCH}" STATE_FILE="${STATE_FILE}" \ python3 -c " import json, os sf = os.environ.get('STATE_FILE', os.path.expanduser('~/.claude/recall/state.json')) try: d = json.load(open(sf)) except Exception: d = {} d['last_update_check'] = int(os.environ['NOW_EPOCH']) rv = os.environ.get('REMOTE_VERSION', '') iv = os.environ.get('INSTALLED_VERSION', '1.0.0') if rv: try: rv_parts = [int(x) for x in rv.split('.') if x] iv_parts = [int(x) for x in iv.split('.') if x] if rv_parts and rv_parts > iv_parts: d['available_version'] = rv elif 'available_version' in d: del d['available_version'] except Exception: pass json.dump(d, open(sf, 'w')) " 2>/dev/null || true elif command -v jq &>/dev/null; then if [[ -f "${STATE_FILE}" ]]; then TMP="$(jq --argjson luc "${NOW_EPOCH}" --arg rv "${REMOTE_VERSION}" --arg iv "${INSTALLED_VERSION}" ' . + {last_update_check: $luc} | if ($rv | length) > 0 then ( if (($rv | split(".") | map(tonumber)) > ($iv | split(".") | map(tonumber))) then . + {available_version: $rv} else del(.available_version) end ) else . end ' "${STATE_FILE}" 2>/dev/null || true)" else TMP="{\"last_update_check\":${NOW_EPOCH}}" fi [[ -n "${TMP}" ]] && echo "${TMP}" > "${STATE_FILE}" || true fi fi # ─── Read auto_update preference from config.json (default: true) ──────────── # Env var RECALL_AUTO_UPDATE=false takes precedence over config.json. AUTO_UPDATE="true" if [[ -f "${CONFIG_FILE}" ]]; then if command -v jq &>/dev/null; then _au="$(jq -r '.auto_update // empty' "${CONFIG_FILE}" 2>/dev/null || true)" [[ "${_au}" == "false" ]] && AUTO_UPDATE="false" elif command -v python3 &>/dev/null; then _au="$(python3 -c "import json; d=json.load(open('${CONFIG_FILE}')); print(d.get('auto_update',''))" 2>/dev/null || true)" [[ "${_au}" == "False" || "${_au}" == "false" ]] && AUTO_UPDATE="false" fi fi # Env var override — takes precedence over config.json [[ "${RECALL_AUTO_UPDATE:-}" == "false" ]] && AUTO_UPDATE="false" # ─── Auto-update if a newer version was detected ───────────────────────────── # REMOTE_VERSION is set above when the daily check ran and found a new version. # AVAILABLE_VERSION is the fallback from a previous session's check. AVAILABLE_VERSION="${REMOTE_VERSION}" if [[ -z "${AVAILABLE_VERSION}" ]] && [[ -f "${STATE_FILE}" ]]; then if command -v jq &>/dev/null; then AVAILABLE_VERSION="$(jq -r '.available_version // empty' "${STATE_FILE}" 2>/dev/null || true)" elif command -v python3 &>/dev/null; then AVAILABLE_VERSION="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('available_version',''))" 2>/dev/null || true)" fi fi if [[ -n "${AVAILABLE_VERSION}" ]] && _recall_version_newer "${AVAILABLE_VERSION}" "${INSTALLED_VERSION}"; then echo "" echo "> 🔄 Recall update: ${INSTALLED_VERSION} → ${AVAILABLE_VERSION}" if [[ -n "${REMOTE_CHANGELOG}" ]]; then echo ">" echo "> What's new:" echo "${REMOTE_CHANGELOG}" | while IFS= read -r line; do echo ">$line"; done fi echo ">" if "${IS_PLUGIN_INSTALL}"; then if [[ "${AUTO_UPDATE}" == "false" ]]; then echo "> Auto-update is disabled. To update manually:" echo "> /plugin install recall" else # Plugin mode: download updated scripts directly into SCRIPT_DIR in the background. # This makes plugin updates seamless — no user action required beyond restarting. UPDATE_LOG="${HOME}/.claude/recall/update.log" _PLUGIN_JSON="${SCRIPT_DIR}/../.claude-plugin/plugin.json" _NEW_VERSION="${AVAILABLE_VERSION}" ( _ok=true for _sf in session-start.sh observe.sh statusline.sh stop.sh stop-summarize.sh pre-compact.sh compact-restore.sh session-end.sh; do curl -fsSL --max-time 10 "${RECALL_SERVER_URL}/hooks/file/${_sf}" \ -o "${SCRIPT_DIR}/${_sf}" 2>/dev/null \ && chmod +x "${SCRIPT_DIR}/${_sf}" 2>/dev/null \ || { _ok=false; break; } done if "${_ok}"; then mkdir -p "${SCRIPT_DIR}/lib" curl -fsSL --max-time 10 "${RECALL_SERVER_URL}/hooks/file/lib/config.sh" \ -o "${SCRIPT_DIR}/lib/config.sh" 2>/dev/null \ && chmod +x "${SCRIPT_DIR}/lib/config.sh" 2>/dev/null \ || _ok=false fi # Download rules file (markdown, no chmod needed; failure is non-fatal) if "${_ok}"; then mkdir -p "${SCRIPT_DIR}/../rules" 2>/dev/null || true curl -fsSL --max-time 10 "${RECALL_SERVER_URL}/hooks/file/rules/recall.md" \ -o "${SCRIPT_DIR}/../rules/recall.md" 2>/dev/null || true fi if "${_ok}" && [[ -f "${_PLUGIN_JSON}" ]]; then if command -v python3 &>/dev/null; then _NV="${_NEW_VERSION}" _PJ="${_PLUGIN_JSON}" python3 -c " import json, os pj = os.environ['_PJ']; v = os.environ['_NV'] try: d = json.load(open(pj)); d['version'] = v; json.dump(d, open(pj, 'w'), indent=2) except Exception: pass " 2>/dev/null || true elif command -v jq &>/dev/null; then _tmp="$(jq --arg v "${_NEW_VERSION}" '.version = $v' "${_PLUGIN_JSON}" 2>/dev/null || true)" [[ -n "${_tmp}" ]] && echo "${_tmp}" > "${_PLUGIN_JSON}" || true fi fi # Also refresh the standalone tree when it exists as a different # directory. The settings statusLine points there, and plugin-managed # installs no longer register its session-start, so nothing else # keeps its scripts - or the version it displays - current. _STANDALONE_DIR="${HOME}/.claude/plugins/recall/scripts" if "${_ok}" && [[ -d "${_STANDALONE_DIR}" ]] && [[ "${_STANDALONE_DIR}" != "${SCRIPT_DIR}" ]]; then for _sf in session-start.sh observe.sh statusline.sh stop.sh stop-summarize.sh pre-compact.sh compact-restore.sh session-end.sh; do cp "${SCRIPT_DIR}/${_sf}" "${_STANDALONE_DIR}/${_sf}" 2>/dev/null || true done mkdir -p "${_STANDALONE_DIR}/lib" 2>/dev/null || true cp "${SCRIPT_DIR}/lib/config.sh" "${_STANDALONE_DIR}/lib/config.sh" 2>/dev/null || true _SPJ="${HOME}/.claude/plugins/recall/.claude-plugin/plugin.json" if [[ -f "${_SPJ}" ]] && command -v python3 &>/dev/null; then _NV="${_NEW_VERSION}" _PJ="${_SPJ}" python3 -c " import json, os pj = os.environ['_PJ']; v = os.environ['_NV'] try: d = json.load(open(pj)); d['version'] = v; json.dump(d, open(pj, 'w'), indent=2) except Exception: pass " 2>/dev/null || true fi fi ) > "${UPDATE_LOG}" 2>&1 & disown $! 2>/dev/null || true echo "> Updating in background — restart Claude Code once complete." echo "> To disable auto-update: set RECALL_AUTO_UPDATE=false in your shell profile" echo "> or set auto_update: false in ~/.claude/recall/config.json" fi elif [[ "${AUTO_UPDATE}" == "false" ]]; then echo "> Auto-update is disabled. To update manually, run: /recall-update" echo "> or: curl -fsSL ${RECALL_SERVER_URL}/install-hooks | bash -s -- --api-key \${RECALL_API_KEY}" else # Auto-update hooks in the background so the hook completes within its timeout. # install.sh is idempotent and safe to re-run. UPDATE_LOG="${HOME}/.claude/recall/update.log" ( curl -fsSL --max-time 30 "${RECALL_SERVER_URL}/install-hooks" \ | bash -s -- --api-key "${RECALL_API_KEY}" --server-url "${RECALL_SERVER_URL}" ) > "${UPDATE_LOG}" 2>&1 & disown $! 2>/dev/null || true echo "> Updating in background — restart Claude Code once complete." echo "> To disable auto-update: set RECALL_AUTO_UPDATE=false in your shell profile" echo "> or set auto_update: false in ~/.claude/recall/config.json" fi echo "" fi # ─── The mod needs a plugin update (once per version) ──────────────────────── # From 1.18.0 the plugin carries a Claude Code mod (hooks/register.js). The background update above # downloads scripts only, never code that runs inside Claude Code, so a plugin tree at 1.18.0 or later # without register.js has the new scripts but not the mod. Say how to get it, once per version. if "${IS_PLUGIN_INSTALL}" && [[ ! -f "${SCRIPT_DIR}/../hooks/register.js" ]] \ && ! _recall_version_newer "1.18.0" "${INSTALLED_VERSION}"; then _mod_notice_for="" if [[ -f "${STATE_FILE}" ]]; then if command -v jq &>/dev/null; then _mod_notice_for="$(jq -r '.mod_notice_for // empty' "${STATE_FILE}" 2>/dev/null || true)" elif command -v python3 &>/dev/null; then _mod_notice_for="$(python3 -c "import json; print(json.load(open('${STATE_FILE}')).get('mod_notice_for',''))" 2>/dev/null || true)" fi fi if [[ "${_mod_notice_for}" != "${INSTALLED_VERSION}" ]]; then echo "> 🧠 Recall ${INSTALLED_VERSION} shows its own line above the prompt on Claude Code 2.1.287 or later." echo "> It comes with a plugin update, not the background one. In a terminal:" echo "> claude plugin marketplace update recall-claude-plugin" echo "> claude plugin update recall@recall-claude-plugin" echo "> then restart Claude Code. Nothing else to install." echo "" if command -v python3 &>/dev/null; then _V="${INSTALLED_VERSION}" _SF="${STATE_FILE}" python3 -c " import json, os sf = os.environ['_SF'] try: d = json.load(open(sf)) except Exception: d = {} d['mod_notice_for'] = os.environ['_V'] json.dump(d, open(sf, 'w')) " 2>/dev/null || true elif command -v jq &>/dev/null && [[ -f "${STATE_FILE}" ]]; then _tmp="$(jq --arg v "${INSTALLED_VERSION}" '.mod_notice_for = $v' "${STATE_FILE}" 2>/dev/null || true)" [[ -n "${_tmp}" ]] && echo "${_tmp}" > "${STATE_FILE}" || true fi fi fi # Reset session counter and generate a unique session name in state.json # The session name (e.g. "happy-dog") identifies this Claude Code instance # in the session registry so events can be routed to it specifically. if command -v python3 &>/dev/null; then SESSION_NAME="$(python3 -c " import json, os, random ADJECTIVES = [ 'happy','lucky','brave','calm','eager','fancy','gentle','jolly', 'kind','lively','merry','neat','proud','quiet','shiny','swift', 'vivid','witty','zesty','bold','crisp','daring','electric','fuzzy', 'golden','honest','icy','jazzy','keen','leafy','mighty','noble', 'orange','peppy','quirky','rosy','savvy','tidy','ultra','vibrant', 'warm','xenial','youthful','zealous','agile','bright','cosmic', 'dazzling','earnest','fearless','graceful','humble','iconic', ] ANIMALS = [ 'dog','cat','fox','owl','bear','deer','duck','frog','goat','hawk', 'ibis','jay','kiwi','lamb','lynx','mole','newt','orca','pony', 'quail','raven','seal','toad','vole','wren','yak','zebu','bison', 'crane','dove','elk','finch','gnu','heron','iguana','jaguar', 'koala','lemur','mink','narwhal','ocelot','panda','rabbit', 'sloth','tapir','urial','viper','walrus','xerus','yapok', ] adj = random.choice(ADJECTIVES) animal = random.choice(ANIMALS) print(f'{adj}-{animal}') " 2>/dev/null || echo "recall-session")" python3 -c " import json, os sf = '${STATE_FILE}' try: d = json.load(open(sf)) except Exception: d = {} d['session_memories'] = 0 d['session_name'] = '${SESSION_NAME}' json.dump(d, open(sf, 'w')) " 2>/dev/null || true elif command -v jq &>/dev/null; then SESSION_NAME="$(python3 -c "import random; a=['happy','lucky','brave','calm','eager']; n=['dog','cat','fox','owl','bear']; print(random.choice(a)+'-'+random.choice(n))" 2>/dev/null || echo "recall-session")" if [[ -f "${STATE_FILE}" ]]; then TMP="$(jq --arg sn "${SESSION_NAME}" '.session_memories = 0 | .session_name = $sn' "${STATE_FILE}" 2>/dev/null || true)" else TMP="{\"session_memories\":0,\"session_name\":\"${SESSION_NAME}\"}" fi [[ -n "${TMP}" ]] && echo "${TMP}" > "${STATE_FILE}" || true fi # ─── Register session with Recall server ───────────────────────────────────── # Fire-and-forget: best-effort registration; if it fails the session still works. if [[ -n "${SESSION_NAME:-}" && -n "${RECALL_API_KEY:-}" ]]; then SESSION_ID="$(python3 -c "import os; print(os.urandom(8).hex())" 2>/dev/null || echo "$$")" (curl --silent --max-time 3 --fail \ --request POST \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ --header "Content-Type: application/json" \ --data "{\"name\":\"${SESSION_NAME}\",\"session_id\":\"${SESSION_ID}\",\"workspace_path\":\"${RECALL_WORKSPACE:-}\",\"git_remote\":\"${RECALL_GIT_REMOTE:-}\"}" \ "${RECALL_SERVER_URL}/api/sessions" \ ) > /dev/null 2>&1 & disown $! 2>/dev/null || true fi # ─── Fetch context and print activity indicator ──────────────────────────────── # Capture context first so we can show the banner even when context is empty. CONTEXT="$(curl \ --silent \ --max-time 5 \ --fail \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ --header "X-Recall-Workspace: ${RECALL_WORKSPACE}" \ --header "X-Recall-Git-Remote: ${RECALL_GIT_REMOTE}" \ "${RECALL_SERVER_URL}/api/context?limit=20" \ 2>/dev/null || true)" # Always print the activity indicator so users can confirm Recall is running. echo "> 🧠 Recall ${INSTALLED_VERSION} active" if [[ -n "${CONTEXT}" ]]; then echo ">" printf '%s\n' "${CONTEXT}" fi ENDOFHOOK chmod +x "${HOOKS_DIR}/session-start.sh" cat > "${HOOKS_DIR}/observe.sh" <<'ENDOFHOOK' #!/usr/bin/env bash # Recall observe hook (PostToolUse, PostToolUseFailure) # Reads Claude Code tool event from stdin, POSTs Bash FAILURES to Recall. # # Policy (2026-08-30): errors + digest only. Per-call capture of # Read/Grep/Glob/Write/Edit/Bash-success produced 99% of stored memories # (561k records, 8.5GB) while being retrieved essentially never, and it # crowded real memories out of every search. A failing command is the one # tool event worth remembering; the session digest lives in # stop-summarize.sh. # # Registered (hooks.json, or settings.json for a standalone install) under: # hooks.PostToolUse[].hooks[].command and hooks.PostToolUseFailure[].hooks[].command # matcher: "Bash" # async: true (non-blocking - Claude does not wait for this hook) # timeout: 10 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load config - silently exit if something goes wrong # shellcheck source=lib/config.sh source "${SCRIPT_DIR}/lib/config.sh" || exit 0 # No API key - nothing to do [[ -z "${RECALL_API_KEY}" ]] && exit 0 # Read JSON from stdin (Claude Code passes {"tool_name","tool_input","tool_response"}) STDIN_DATA="$(cat)" # Extract tool_name if command -v jq &>/dev/null; then TOOL_NAME="$(echo "${STDIN_DATA}" | jq -r '.tool_name // empty' 2>/dev/null || true)" elif command -v python3 &>/dev/null; then TOOL_NAME="$(echo "${STDIN_DATA}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null || true)" else TOOL_NAME="$(echo "${STDIN_DATA}" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)" fi # Only Bash failures are captured. Everything else is noise at storage time. [[ "${TOOL_NAME}" != "Bash" ]] && exit 0 # Recall's Claude Code mod (hooks/register.js, Claude Code 2.1.287+) records these failures in process and # refreshes ~/.claude/recall/mod-heartbeat- every 15 s. While that heartbeat is fresh this script # stands down; a stale one (the mod unloaded mid-session) puts this script back in charge. if command -v jq &>/dev/null; then SESSION_ID="$(echo "${STDIN_DATA}" | jq -r '.session_id // empty' 2>/dev/null || true)" elif command -v python3 &>/dev/null; then SESSION_ID="$(echo "${STDIN_DATA}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('session_id',''))" 2>/dev/null || true)" else SESSION_ID="" fi if [[ "${SESSION_ID}" =~ ^[A-Za-z0-9_-]+$ ]]; then _beat="$(cat "${HOME}/.claude/recall/mod-heartbeat-${SESSION_ID}" 2>/dev/null || true)" if [[ "${_beat}" =~ ^[0-9]+$ ]] && (( $(date +%s) - _beat < 45 )); then exit 0 fi fi # Extract the command if command -v jq &>/dev/null; then COMMAND="$(echo "${STDIN_DATA}" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" elif command -v python3 &>/dev/null; then COMMAND="$(echo "${STDIN_DATA}" | python3 -c "import json,sys; d=json.load(sys.stdin); print((d.get('tool_input') or {}).get('command',''))" 2>/dev/null || true)" else COMMAND="" fi [[ -z "${COMMAND}" ]] && exit 0 # A command that exits non-zero, times out or is refused fires PostToolUseFailure, not PostToolUse (Claude Code # 2.1.288). That event has no tool_response: the output is in "error", and the event itself says the call failed. # An interrupt (the person pressed Esc) is not a failing command. if command -v jq &>/dev/null; then HOOK_EVENT="$(echo "${STDIN_DATA}" | jq -r '.hook_event_name // empty' 2>/dev/null || true)" IS_INTERRUPT="$(echo "${STDIN_DATA}" | jq -r '.is_interrupt // false' 2>/dev/null || true)" elif command -v python3 &>/dev/null; then HOOK_EVENT="$(echo "${STDIN_DATA}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('hook_event_name',''))" 2>/dev/null || true)" IS_INTERRUPT="$(echo "${STDIN_DATA}" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('is_interrupt') is True else 'false')" 2>/dev/null || true)" else HOOK_EVENT="" IS_INTERRUPT="false" fi FAILED_EVENT=false if [[ "${HOOK_EVENT}" == "PostToolUseFailure" ]]; then [[ "${IS_INTERRUPT}" == "true" ]] && exit 0 FAILED_EVENT=true fi # Detect failure from tool output. No failure -> nothing to store. TOOL_OUTPUT="" if "${FAILED_EVENT}"; then if command -v jq &>/dev/null; then TOOL_OUTPUT="$(echo "${STDIN_DATA}" | jq -r '(.error // "") | tostring | .[:20000]' 2>/dev/null || true)" elif command -v python3 &>/dev/null; then TOOL_OUTPUT="$(echo "${STDIN_DATA}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(str(d.get('error') or '')[:20000])" 2>/dev/null || true)" fi [[ -z "${TOOL_OUTPUT}" ]] && TOOL_OUTPUT="(no error text)" elif command -v python3 &>/dev/null; then TOOL_OUTPUT="$(echo "${STDIN_DATA}" | python3 -c " import json, sys try: d = json.load(sys.stdin) # Claude Code may use 'tool_response' or 'tool_output' depending on version r = d.get('tool_response') or d.get('tool_output', '') if isinstance(r, dict): out = r.get('stderr', '') or r.get('stdout', '') or '' else: out = str(r) print(out[:20000]) except Exception: print('') " 2>/dev/null || true)" elif command -v jq &>/dev/null; then TOOL_OUTPUT="$(echo "${STDIN_DATA}" | jq -r \ '(.tool_response // .tool_output // "") | if type == "object" then (.stderr // .stdout // "") else . end | .[:20000]' \ 2>/dev/null || true)" fi if ! "${FAILED_EVENT}" && { [[ -z "${TOOL_OUTPUT}" ]] || ! echo "${TOOL_OUTPUT:0:500}" | grep -qiE '(^error:|npm ERR!|FAILED|command not found|non-zero exit|exit code [1-9])'; }; then exit 0 fi # Secrets in the command or its output never leave this machine: each match becomes [REDACTED] before the POST, and # if redaction cannot run, nothing is sent. The list is the same as PATTERNS in the plugin's hooks/redact.js # (hooks.test.sh compares them and runs both against plugin/recall/tests/redaction-fixtures.js). A named group "k" # is text kept in front of [REDACTED]; \x27 stands for a single quote. Bare hex is never redacted, so SHAs survive. REDACT_PATTERNS='[ ["-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----|[\\s\\S]*)", ""], ["(?authorization\\s*[:=]\\s*)(?:(?:bearer|basic|token|digest)\\s+)?[^\\s\"\\x27,;]+", "i"], ["(?\\bbearer\\s+)[A-Za-z0-9._~+/=-]{8,}", "i"], ["(?\\b(?:set-)?cookie[\"\\x27]?[ \\t]*:[ \\t]*)[^\\r\\n\"\\x27]+", "i"], ["(?\\b[a-z][a-z0-9+.-]*://)[^/\\s:@\"\\x27]+:[^/\\s@\"\\x27]+(?=@)", "i"], ["(?\\bhooks\\.slack\\.com/(?:services|workflows|triggers)/)[A-Za-z0-9/_-]+", "i"], ["(?\\b[a-z0-9_.-]*?(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)=[\"\\x27]?)[^\\s\"\\x27&;,)]+", "i"], ["(?\\b[a-z0-9_.-]*?(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)[\"\\x27]?[ \\t]*[:=][ \\t]*[\"\\x27]?)[^\\s\"\\x27&;,)]{6,}", "i"], ["(?\\b[A-Z][A-Z0-9_]*_(?:KEY|PASS|PWD|CREDENTIALS?)=[\"\\x27]?)[^\\s\"\\x27&;,)]+", ""], ["(?(?:^|\\s)--?(?:[a-z0-9]+-)*(?:token|secret|password|passwd|pass|api-?key|access-?key)(?:=|\\s+)[\"\\x27]?)[^\\s\"\\x27]+", "i"], ["(?\\b(?:mysql|mysqldump|mysqladmin|mariadb)\\b[^\\n]*?[ \\t]-p)[^\\s\"\\x27]+", ""], ["\\b(?:sk-ant-|sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|glpat-|ya29\\.|xox[abpr]-|crsr_|ft_live_|ft_test_|ft_run_|rk_live_|rk_test_|sk_live_|sk_test_|whsec_)[A-Za-z0-9_.-]{8,}", ""], ["\\bAKIA[0-9A-Z]{16}\\b", ""], ["\\bAIza[0-9A-Za-z_-]{30,}", ""], ["\\beyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}", ""], ["\\bnpm_[A-Za-z0-9]{30,}", ""] ]' redact() { if command -v python3 &>/dev/null; then REDACT_PATTERNS="${REDACT_PATTERNS}" python3 -c ' import json, os, re, sys s = sys.stdin.read() for p, f in json.loads(os.environ["REDACT_PATTERNS"]): s = re.sub(p.replace("(?", "(?P"), lambda m: (m.groupdict().get("k") or "") + "[REDACTED]", s, flags=re.I if "i" in f else 0) sys.stdout.write(s)' elif command -v jq &>/dev/null; then jq -Rrs --argjson pats "${REDACT_PATTERNS}" 'reduce $pats[] as $p (.; gsub($p[0]; "\(.k // "")[REDACTED]"; $p[1]))' else return 1 fi } COMMAND="$(printf '%s' "${COMMAND}" | redact 2>/dev/null)" || exit 0 TOOL_OUTPUT="$(printf '%s' "${TOOL_OUTPUT}" | redact 2>/dev/null)" || exit 0 # A failed command plus its output excerpt - enough context to be findable # and useful later, unlike a bare command line. OBSERVATION_CONTENT="[Bash error] ${COMMAND:0:200} Output: ${TOOL_OUTPUT:0:300}" IMPORTANCE=6 # Build JSON payload if command -v jq &>/dev/null; then PAYLOAD="$(jq -n \ --arg content "${OBSERVATION_CONTENT}" \ --argjson importance "${IMPORTANCE}" \ '{content: $content, context_type: "information", importance: $importance, tags: ["auto-hook", "bash", "error"], is_global: false}')" elif command -v python3 &>/dev/null; then PAYLOAD="$(OBSERVATION_CONTENT="${OBSERVATION_CONTENT}" IMPORTANCE="${IMPORTANCE}" python3 -c " import json, os print(json.dumps({ 'content': os.environ['OBSERVATION_CONTENT'], 'context_type': 'information', 'importance': int(os.environ.get('IMPORTANCE', 6)), 'tags': ['auto-hook', 'bash', 'error'], 'is_global': False }))")" else # Minimal JSON construction - escape quotes and newlines in content ESCAPED="${OBSERVATION_CONTENT//\"/\\\"}" ESCAPED="${ESCAPED//$'\n'/\\n}" PAYLOAD="{\"content\":\"${ESCAPED}\",\"context_type\":\"information\",\"importance\":${IMPORTANCE},\"tags\":[\"auto-hook\",\"bash\",\"error\"],\"is_global\":false}" fi # POST observation - fire-and-forget, ignore errors # Capture HTTP status code to conditionally update local state HTTP_STATUS="$(curl \ --silent \ --max-time 5 \ --output /dev/null \ --write-out "%{http_code}" \ --request POST \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ --header "X-Recall-Workspace: ${RECALL_WORKSPACE}" \ --header "X-Recall-Git-Remote: ${RECALL_GIT_REMOTE}" \ --data "${PAYLOAD}" \ "${RECALL_SERVER_URL}/api/memories" \ 2>/dev/null || echo "000")" # On success (2xx), update local state file for statusline activity indicator if [[ "${HTTP_STATUS}" =~ ^2 ]]; then STATE_FILE="${HOME}/.claude/recall/state.json" NOW="$(date +%s 2>/dev/null || echo 0)" COUNT=1 if [[ -f "${STATE_FILE}" ]]; then if command -v jq &>/dev/null; then COUNT="$(jq -r '.session_memories // 0' "${STATE_FILE}" 2>/dev/null || echo 0)" COUNT=$(( COUNT + 1 )) elif command -v python3 &>/dev/null; then COUNT="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('session_memories',0)+1)" 2>/dev/null || echo 1)" fi fi if command -v python3 &>/dev/null; then NOW="${NOW}" COUNT="${COUNT}" STATE_FILE="${STATE_FILE}" python3 -c " import json, os sf = os.environ.get('STATE_FILE', os.path.expanduser('~/.claude/recall/state.json')) try: d = json.load(open(sf)) except Exception: d = {} d['last_stored'] = int(os.environ['NOW']) d['session_memories'] = int(os.environ['COUNT']) json.dump(d, open(sf, 'w')) " 2>/dev/null || true elif command -v jq &>/dev/null; then if [[ -f "${STATE_FILE}" ]]; then TMP="$(jq --argjson ls "${NOW}" --argjson sm "${COUNT}" \ '. + {last_stored: $ls, session_memories: $sm}' "${STATE_FILE}" 2>/dev/null || true)" else TMP="{\"last_stored\":${NOW},\"session_memories\":${COUNT}}" fi [[ -n "${TMP}" ]] && echo "${TMP}" > "${STATE_FILE}" || true fi fi ENDOFHOOK chmod +x "${HOOKS_DIR}/observe.sh" cat > "${HOOKS_DIR}/pre-compact.sh" <<'ENDOFHOOK' #!/usr/bin/env bash # Recall pre-compact hook # Saves full session state before Claude Code compacts the context window. # Writes to two places: # 1. ~/.claude/recall/pre-compact-state.json — read by compact-restore.sh # for immediate post-compaction context restoration # 2. /api/memories — long-term storage so future sessions know a compaction # occurred and what was active at that point # # Registered in settings.json under: # hooks.PreCompact[].hooks[].command # timeout: 10 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load config — silently exit if something goes wrong # shellcheck source=lib/config.sh source "${SCRIPT_DIR}/lib/config.sh" || exit 0 # No API key — nothing to do [[ -z "${RECALL_API_KEY}" ]] && exit 0 STATE_FILE="${HOME}/.claude/recall/state.json" PRE_COMPACT_FILE="${HOME}/.claude/recall/pre-compact-state.json" # ─── Read current session state ─────────────────────────────────────────────── SESSION_NAME="" SESSION_MEMORIES=0 LAST_STORED=0 if [[ -f "${STATE_FILE}" ]]; then if command -v python3 &>/dev/null; then SESSION_NAME="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('session_name',''))" 2>/dev/null || true)" SESSION_MEMORIES="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('session_memories',0))" 2>/dev/null || echo 0)" LAST_STORED="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('last_stored',0))" 2>/dev/null || echo 0)" elif command -v jq &>/dev/null; then SESSION_NAME="$(jq -r '.session_name // empty' "${STATE_FILE}" 2>/dev/null || true)" SESSION_MEMORIES="$(jq -r '.session_memories // 0' "${STATE_FILE}" 2>/dev/null || echo 0)" LAST_STORED="$(jq -r '.last_stored // 0' "${STATE_FILE}" 2>/dev/null || echo 0)" fi fi SESSION_MEMORIES="${SESSION_MEMORIES:-0}" LAST_STORED="${LAST_STORED:-0}" NOW="$(date +%s 2>/dev/null || echo 0)" CWD_PATH="$(pwd)" # ─── Write local state file for compact-restore.sh ──────────────────────────── if command -v python3 &>/dev/null; then SESSION_NAME="${SESSION_NAME}" SESSION_MEMORIES="${SESSION_MEMORIES}" \ LAST_STORED="${LAST_STORED}" NOW="${NOW}" \ WORKSPACE="${RECALL_WORKSPACE}" CWD_PATH="${CWD_PATH}" \ GIT_REMOTE="${RECALL_GIT_REMOTE}" PRE_COMPACT_FILE="${PRE_COMPACT_FILE}" \ python3 -c " import json, os data = { 'session_name': os.environ.get('SESSION_NAME', ''), 'session_memories': int(os.environ.get('SESSION_MEMORIES', 0) or 0), 'last_stored': int(os.environ.get('LAST_STORED', 0) or 0), 'timestamp': int(os.environ.get('NOW', 0) or 0), 'workspace': os.environ.get('WORKSPACE', ''), 'cwd': os.environ.get('CWD_PATH', ''), 'git_remote': os.environ.get('GIT_REMOTE', ''), } json.dump(data, open(os.environ['PRE_COMPACT_FILE'], 'w')) " 2>/dev/null || true elif command -v jq &>/dev/null; then jq -n \ --arg session_name "${SESSION_NAME}" \ --argjson session_memories "${SESSION_MEMORIES:-0}" \ --argjson last_stored "${LAST_STORED:-0}" \ --argjson timestamp "${NOW:-0}" \ --arg workspace "${RECALL_WORKSPACE}" \ --arg cwd "${CWD_PATH}" \ --arg git_remote "${RECALL_GIT_REMOTE}" \ '{session_name: $session_name, session_memories: $session_memories, last_stored: $last_stored, timestamp: $timestamp, workspace: $workspace, cwd: $cwd, git_remote: $git_remote}' \ > "${PRE_COMPACT_FILE}" 2>/dev/null || true fi # ─── Store memory with richer content ───────────────────────────────────────── CONTENT="Pre-compact checkpoint. Session: ${SESSION_NAME:-unknown}. Project: ${RECALL_GIT_REMOTE:-${RECALL_WORKSPACE}}. Memories this session: ${SESSION_MEMORIES}. CWD: ${CWD_PATH}." if command -v jq &>/dev/null; then PAYLOAD="$(jq -n \ --arg content "${CONTENT}" \ '{content: $content, context_type: "information", importance: 6, tags: ["auto-hook", "pre-compact"], is_global: false}')" elif command -v python3 &>/dev/null; then PAYLOAD="$(CONTENT="${CONTENT}" python3 -c " import json, os print(json.dumps({ 'content': os.environ['CONTENT'], 'context_type': 'information', 'importance': 6, 'tags': ['auto-hook', 'pre-compact'], 'is_global': False }))")" else ESCAPED="${CONTENT//\"/\\\"}" PAYLOAD="{\"content\":\"${ESCAPED}\",\"context_type\":\"information\",\"importance\":6,\"tags\":[\"auto-hook\",\"pre-compact\"],\"is_global\":false}" fi curl \ --silent \ --max-time 5 \ --output /dev/null \ --request POST \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ --header "X-Recall-Workspace: ${RECALL_WORKSPACE}" \ --header "X-Recall-Git-Remote: ${RECALL_GIT_REMOTE}" \ --data "${PAYLOAD}" \ "${RECALL_SERVER_URL}/api/memories" \ 2>/dev/null || true ENDOFHOOK chmod +x "${HOOKS_DIR}/pre-compact.sh" cat > "${HOOKS_DIR}/session-end.sh" <<'ENDOFHOOK' #!/usr/bin/env bash # Recall session-end hook — deregisters this session from the Recall server. # Allows the server to know the session is no longer active. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load config — silently exit if something goes wrong # shellcheck source=lib/config.sh source "${SCRIPT_DIR}/lib/config.sh" || exit 0 # No API key — nothing to do [[ -z "${RECALL_API_KEY}" ]] && exit 0 # ─── Read session name from state file ─────────────────────────────────────── STATE_FILE="${HOME}/.claude/recall/state.json" SESSION_NAME="" if [[ -f "${STATE_FILE}" ]]; then if command -v python3 &>/dev/null; then SESSION_NAME="$(python3 -c " import json try: d = json.load(open('${STATE_FILE}')) print(d.get('session_name', '')) except Exception: print('') " 2>/dev/null || true)" elif command -v jq &>/dev/null; then SESSION_NAME="$(jq -r '.session_name // empty' "${STATE_FILE}" 2>/dev/null || true)" fi fi # No session name — nothing to deregister [[ -z "${SESSION_NAME}" ]] && exit 0 # ─── Deregister session (best-effort, synchronous with short timeout) ───────── curl --silent --max-time 3 \ --request DELETE \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ "${RECALL_SERVER_URL}/api/sessions/${SESSION_NAME}" \ > /dev/null 2>&1 || true exit 0 ENDOFHOOK chmod +x "${HOOKS_DIR}/session-end.sh" cat > "${HOOKS_DIR}/stop.sh" <<'ENDOFHOOK' #!/usr/bin/env bash # Recall stop hook — polls for pending events and feeds them to Claude as tasks. # # When an event is pending (Honeybadger alert, manual trigger, etc.) this hook # returns {"decision":"block","reason":""} which prevents Claude from # stopping and injects the event as its next task. # # stop_hook_active in the hook input JSON guards against infinite loops: # Claude Code sets this to true when a stop hook already blocked once, so we # skip polling and allow the stop. # # Registered in settings.json under: # hooks.Stop[].hooks[].command (synchronous — NO async:true) # timeout: 5 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load config — silently exit (allow stop) if something goes wrong # shellcheck source=lib/config.sh source "${SCRIPT_DIR}/lib/config.sh" || exit 0 # No API key — nothing to do, allow stop [[ -z "${RECALL_API_KEY}" ]] && exit 0 # ─── Read hook input from stdin ─────────────────────────────────────────────── # Claude Code provides JSON: {"stop_hook_active": true/false, ...} # Reading via python3 is safer than piping to jq when special chars are present. HOOK_INPUT="" if [ -t 0 ]; then # No stdin (manual invocation) — proceed HOOK_INPUT="{}" else HOOK_INPUT="$(cat)" fi # ─── Guard: stop_hook_active prevents infinite loops ───────────────────────── # When Claude Code sets stop_hook_active=true it means a Stop hook already # blocked once this turn. We must allow the stop to avoid an infinite loop. STOP_HOOK_ACTIVE="false" if command -v python3 &>/dev/null; then STOP_HOOK_ACTIVE="$(echo "${HOOK_INPUT}" | python3 -c \ "import json,sys; d=json.load(sys.stdin); print(str(d.get('stop_hook_active',False)).lower())" \ 2>/dev/null || echo "false")" elif command -v jq &>/dev/null; then STOP_HOOK_ACTIVE="$(echo "${HOOK_INPUT}" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")" fi [[ "${STOP_HOOK_ACTIVE}" == "true" ]] && exit 0 # ─── Read session name from state file ──────────────────────────────────────── STATE_FILE="${HOME}/.claude/recall/state.json" SESSION_NAME="" if [[ -f "${STATE_FILE}" ]]; then if command -v python3 &>/dev/null; then SESSION_NAME="$(python3 -c " import json try: d = json.load(open('${STATE_FILE}')) print(d.get('session_name', '')) except Exception: print('') " 2>/dev/null || true)" elif command -v jq &>/dev/null; then SESSION_NAME="$(jq -r '.session_name // empty' "${STATE_FILE}" 2>/dev/null || true)" fi fi # ─── Self-heal: generate + register session name for pre-existing sessions ──── # Sessions started before this feature was deployed won't have a session_name in # state.json. Generate one now so they start getting session-targeted events. if [[ -z "${SESSION_NAME}" ]] && command -v python3 &>/dev/null; then SESSION_NAME="$(python3 -c " import json, os, random ADJECTIVES = [ 'happy','lucky','brave','calm','eager','fancy','gentle','jolly', 'kind','lively','merry','neat','proud','quiet','shiny','swift', 'vivid','witty','zesty','bold','crisp','daring','electric','fuzzy', 'golden','honest','icy','jazzy','keen','leafy','mighty','noble', 'orange','peppy','quirky','rosy','savvy','tidy','ultra','vibrant', 'warm','xenial','youthful','zealous','agile','bright','cosmic', 'dazzling','earnest','fearless','graceful','humble','iconic', ] ANIMALS = [ 'dog','cat','fox','owl','bear','deer','duck','frog','goat','hawk', 'ibis','jay','kiwi','lamb','lynx','mole','newt','orca','pony', 'quail','raven','seal','toad','vole','wren','yak','zebu','bison', 'crane','dove','elk','finch','gnu','heron','iguana','jaguar', 'koala','lemur','mink','narwhal','ocelot','panda','rabbit', 'sloth','tapir','urial','viper','walrus','xerus','yapok', ] print(random.choice(ADJECTIVES) + '-' + random.choice(ANIMALS)) " 2>/dev/null || echo "")" if [[ -n "${SESSION_NAME}" ]]; then # Persist name so future hook invocations reuse it python3 -c " import json, os sf = '${STATE_FILE}' try: d = json.load(open(sf)) if os.path.exists(sf) else {} except Exception: d = {} d['session_name'] = '${SESSION_NAME}' json.dump(d, open(sf, 'w')) " 2>/dev/null || true # Register with server (fire-and-forget) SESSION_ID="$(python3 -c "import os; print(os.urandom(8).hex())" 2>/dev/null || echo "$$")" (curl --silent --max-time 3 --fail \ --request POST \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ --header "Content-Type: application/json" \ --data "{\"name\":\"${SESSION_NAME}\",\"session_id\":\"${SESSION_ID}\",\"workspace_path\":\"${RECALL_WORKSPACE:-}\",\"git_remote\":\"${RECALL_GIT_REMOTE:-}\"}" \ "${RECALL_SERVER_URL}/api/sessions" \ ) > /dev/null 2>&1 & disown $! 2>/dev/null || true fi fi # ─── Build events URL with optional workspace and session context ────────────── EVENTS_URL="${RECALL_SERVER_URL}/api/events/next" QUERY_PARAMS="" if [[ -n "${RECALL_WORKSPACE:-}" ]]; then if command -v python3 &>/dev/null; then ENCODED_WS="$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "${RECALL_WORKSPACE}" 2>/dev/null || echo "${RECALL_WORKSPACE// /+}")" else ENCODED_WS="${RECALL_WORKSPACE// /+}" fi QUERY_PARAMS="workspace=${ENCODED_WS}" fi if [[ -n "${RECALL_GIT_REMOTE:-}" ]]; then if command -v python3 &>/dev/null; then ENCODED_GR="$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "${RECALL_GIT_REMOTE}" 2>/dev/null || echo "${RECALL_GIT_REMOTE// /+}")" else ENCODED_GR="${RECALL_GIT_REMOTE// /+}" fi QUERY_PARAMS="${QUERY_PARAMS:+${QUERY_PARAMS}&}git_remote=${ENCODED_GR}" fi if [[ -n "${SESSION_NAME:-}" ]]; then if command -v python3 &>/dev/null; then ENCODED_SN="$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "${SESSION_NAME}" 2>/dev/null || echo "${SESSION_NAME}")" else ENCODED_SN="${SESSION_NAME}" fi QUERY_PARAMS="${QUERY_PARAMS:+${QUERY_PARAMS}&}session_name=${ENCODED_SN}" fi [[ -n "${QUERY_PARAMS}" ]] && EVENTS_URL="${EVENTS_URL}?${QUERY_PARAMS}" # ─── Poll for next pending event ────────────────────────────────────────────── RESPONSE="$(curl \ --silent \ --max-time 4 \ --fail \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ "${EVENTS_URL}" \ 2>/dev/null || true)" [[ -z "${RESPONSE}" ]] && exit 0 # ─── Parse response ─────────────────────────────────────────────────────────── HAS_EVENT="no" EVENT_TYPE="" EVENT_TITLE="" EVENT_BODY="" EVENT_URL="" EVENT_PROMPT="" if command -v python3 &>/dev/null; then HAS_EVENT="$(echo "${RESPONSE}" | python3 -c \ "import json,sys; d=json.load(sys.stdin); print('yes' if d.get('success') and d.get('data') else 'no')" \ 2>/dev/null || echo "no")" if [[ "${HAS_EVENT}" == "yes" ]]; then EVENT_TYPE="$(echo "${RESPONSE}" | python3 -c \ "import json,sys; d=json.load(sys.stdin); print(d['data'].get('type','custom'))" \ 2>/dev/null || echo "custom")" EVENT_TITLE="$(echo "${RESPONSE}" | python3 -c \ "import json,sys; d=json.load(sys.stdin); print(d['data'].get('title',''))" \ 2>/dev/null || echo "")" EVENT_BODY="$(echo "${RESPONSE}" | python3 -c \ "import json,sys; d=json.load(sys.stdin); print(d['data'].get('body',''))" \ 2>/dev/null || echo "")" EVENT_URL="$(echo "${RESPONSE}" | python3 -c \ "import json,sys; d=json.load(sys.stdin); print(d['data'].get('url','') or '')" \ 2>/dev/null || echo "")" EVENT_PROMPT="$(echo "${RESPONSE}" | python3 -c \ "import json,sys; d=json.load(sys.stdin); print(d['data'].get('prompt','') or '')" \ 2>/dev/null || echo "")" fi elif command -v jq &>/dev/null; then HAS_EVENT="$(echo "${RESPONSE}" | jq -r 'if .success and .data then "yes" else "no" end' 2>/dev/null || echo "no")" if [[ "${HAS_EVENT}" == "yes" ]]; then EVENT_TYPE="$(echo "${RESPONSE}" | jq -r '.data.type // "custom"' 2>/dev/null || echo "custom")" EVENT_TITLE="$(echo "${RESPONSE}" | jq -r '.data.title // ""' 2>/dev/null || echo "")" EVENT_BODY="$(echo "${RESPONSE}" | jq -r '.data.body // ""' 2>/dev/null || echo "")" EVENT_URL="$(echo "${RESPONSE}" | jq -r '.data.url // ""' 2>/dev/null || echo "")" EVENT_PROMPT="$(echo "${RESPONSE}" | jq -r '.data.prompt // ""' 2>/dev/null || echo "")" fi fi [[ "${HAS_EVENT}" != "yes" ]] && exit 0 # ─── Build the reason text injected as Claude's next task ──────────────────── REASON="🔔 Recall event received [${EVENT_TYPE}]: ${EVENT_TITLE}" if [[ -n "${EVENT_BODY}" ]]; then REASON="${REASON} ${EVENT_BODY}" fi if [[ -n "${EVENT_PROMPT}" ]]; then REASON="${REASON} **Custom Instructions:** ${EVENT_PROMPT}" fi if [[ -n "${EVENT_URL}" ]]; then REASON="${REASON} Reference: ${EVENT_URL}" fi REASON="${REASON} Please investigate and address the above. When done, continue your normal workflow." # ─── Output block decision as JSON ─────────────────────────────────────────── # python3 handles proper JSON escaping of multi-line reason strings. if command -v python3 &>/dev/null; then python3 -c " import json, sys reason = sys.argv[1] print(json.dumps({'decision': 'block', 'reason': reason})) " "${REASON}" elif command -v jq &>/dev/null; then jq -n --arg reason "${REASON}" '{"decision":"block","reason":$reason}' else # Fallback: basic escaping (newlines → \n, quotes → \") ESCAPED="${REASON//\\/\\\\}" ESCAPED="${ESCAPED//\"/\\\"}" ESCAPED="${ESCAPED//$'\n'/\\n}" printf '{"decision":"block","reason":"%s"}\n' "${ESCAPED}" fi ENDOFHOOK chmod +x "${HOOKS_DIR}/stop.sh" cat > "${HOOKS_DIR}/statusline.sh" <<'ENDOFHOOK' #!/usr/bin/env bash # Recall statusline hook # Reads JSON session data from Claude Code via stdin. # # Two modes: # # WITH Pilot (previous_statusline_cmd set in config.json): # Runs Pilot directly so it can read Claude Code's env vars (model/context info). # If Pilot produces real status output, appends Recall segment to its last line. # If Pilot cannot detect Claude Code session context (e.g. outputs a help message # instead of real status), falls back to standalone mode. # # WITHOUT Pilot (standalone): # Produces a Pilot-style two-line status bar from the stdin JSON: # Line 1: {model} [{context bar}] {pct}% # Line 2: Recall: x.x.x [🧠 N [(Xs ago)]] # # Registered in settings.json under: # statusLine.command = ~/.claude/recall/hooks/statusline.sh # Do NOT use set -e — this script must always produce output even on errors CONFIG_FILE="${RECALL_CONFIG_FILE:-${HOME}/.claude/recall/config.json}" STATE_FILE="${HOME}/.claude/recall/state.json" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" IS_PLUGIN_INSTALL=false [[ "${SCRIPT_DIR}" == *"/plugins/"* ]] && IS_PLUGIN_INSTALL=true # --- Read stdin (JSON session data piped by Claude Code) --- if [ -t 0 ]; then # Not piped (e.g. called manually from terminal) — use empty JSON STDIN_DATA="{}" else STDIN_DATA="$(cat)" fi # --- Recall's Claude Code mod draws the Recall line above the prompt --- # Recall shows once. When this Claude Code runs mods, Recall's mod (hooks/register.js, 1.18.0+) draws Recall's # line above the prompt, so this script leaves its Recall segment and the /api/status call out, from the very # first render (the status line renders before the mod's first heartbeat and does not refresh while idle). # All three must hold: # - Claude Code 2.1.287 or later (the "version" Claude Code passes on stdin); # - the mods rollout flag Claude Code caches in .claude.json is on; # - a recall plugin at 1.18.0 or later with hooks/register.js that Claude Code really loads: an enabled install # in installed_plugins.json (user scope, or a project or local scope for this session's folder), or a folder # listed in CLAUDE_CODE_PLUGIN_DIRS (Claude Code's own variable for loading plugin folders, as --plugin-dir # does). The folder this script runs from never counts by itself: a curl install's tree is not loaded as a # plugin, even if a hooks/register.js ever lands in it. # Otherwise (older Claude Code, mods off, no such install) the segment stays as before. No heartbeat here: # observe.sh keeps its heartbeat check, because recording errors must fail open; this is only display. MOD_DRAWS=false if command -v python3 &>/dev/null; then _mod="$(printf '%s' "${STDIN_DATA}" | python3 -c ' import json, os, sys def ver(v): try: return tuple(int(p) for p in str(v).split("-")[0].split(".")[:3]) except ValueError: return (0,) def load(path): try: with open(path) as f: return json.load(f) except Exception: return {} try: stdin = json.load(sys.stdin) except Exception: stdin = {} if ver(stdin.get("version", "")) < (2, 1, 287): sys.exit() home = os.path.expanduser("~") config_dir = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(home, ".claude") claude_json = os.path.join(os.environ["CLAUDE_CONFIG_DIR"], ".claude.json") if os.environ.get("CLAUDE_CONFIG_DIR") else os.path.join(home, ".claude.json") if load(claude_json).get("cachedGrowthBookFeatures", {}).get("tengu_plugin_hooks_modules") is not True: sys.exit() def has_mod(root, version): return ver(version) >= (1, 18, 0) and os.path.isfile(os.path.join(root, "hooks", "register.js")) for d in filter(None, os.environ.get("CLAUDE_CODE_PLUGIN_DIRS", "").replace(";", ":").split(":")): manifest = load(os.path.join(d, ".claude-plugin", "plugin.json")) if manifest.get("name") == "recall" and has_mod(d, manifest.get("version", "")): print("yes") sys.exit() folder = os.path.realpath((stdin.get("workspace") or {}).get("project_dir") or stdin.get("cwd") or os.getcwd()) enabled = load(os.path.join(config_dir, "settings.json")).get("enabledPlugins", {}) for plugin_id, entries in load(os.path.join(config_dir, "plugins", "installed_plugins.json")).get("plugins", {}).items(): if not plugin_id.startswith("recall@") or enabled.get(plugin_id) is False: continue for e in entries if isinstance(entries, list) else []: here = e.get("scope") == "user" or (e.get("projectPath") and os.path.realpath(e["projectPath"]) == folder) if here and has_mod(e.get("installPath", ""), e.get("version", "")): print("yes") sys.exit() ' 2>/dev/null || true)" [[ "${_mod}" == "yes" ]] && MOD_DRAWS=true fi # --- Read version, API key, server URL, and previous command from config.json --- VERSION="" PREV_CMD="" API_KEY="" SERVER_URL="" if [[ -f "${CONFIG_FILE}" ]]; then if command -v jq &>/dev/null; then VERSION="$(jq -r '.version // empty' "${CONFIG_FILE}" 2>/dev/null || true)" PREV_CMD="$(jq -r '.previous_statusline_cmd // empty' "${CONFIG_FILE}" 2>/dev/null || true)" API_KEY="$(jq -r '.api_key // empty' "${CONFIG_FILE}" 2>/dev/null || true)" SERVER_URL="$(jq -r '.server_url // empty' "${CONFIG_FILE}" 2>/dev/null || true)" elif command -v python3 &>/dev/null; then VERSION="$(python3 -c "import json; d=json.load(open('${CONFIG_FILE}')); print(d.get('version',''),end='')" 2>/dev/null || true)" PREV_CMD="$(python3 -c "import json; d=json.load(open('${CONFIG_FILE}')); print(d.get('previous_statusline_cmd',''),end='')" 2>/dev/null || true)" API_KEY="$(python3 -c "import json; d=json.load(open('${CONFIG_FILE}')); print(d.get('api_key',''),end='')" 2>/dev/null || true)" SERVER_URL="$(python3 -c "import json; d=json.load(open('${CONFIG_FILE}')); print(d.get('server_url',''),end='')" 2>/dev/null || true)" fi fi VERSION="${VERSION:-1.11.9}" PREV_CMD="${PREV_CMD:-}" API_KEY="${API_KEY:-}" SERVER_URL="${SERVER_URL:-}" # For plugin installs, plugin.json is the ground truth for the installed version. # config.json may be stale (only updated by install.sh, not the plugin updater). if "${IS_PLUGIN_INSTALL}"; then _PLUGIN_JSON="${SCRIPT_DIR}/../.claude-plugin/plugin.json" if [[ -f "${_PLUGIN_JSON}" ]]; then if command -v python3 &>/dev/null; then _pv="$(python3 -c "import json; d=json.load(open('${_PLUGIN_JSON}')); print(d.get('version',''),end='')" 2>/dev/null || true)" elif command -v jq &>/dev/null; then _pv="$(jq -r '.version // empty' "${_PLUGIN_JSON}" 2>/dev/null || true)" fi [[ -n "${_pv}" ]] && VERSION="${_pv}" fi fi # --- Read activity state --- SESSION_MEMORIES=0 LAST_STORED=0 if [[ -f "${STATE_FILE}" ]]; then if command -v jq &>/dev/null; then SESSION_MEMORIES="$(jq -r '.session_memories // 0' "${STATE_FILE}" 2>/dev/null || echo 0)" LAST_STORED="$(jq -r '.last_stored // 0' "${STATE_FILE}" 2>/dev/null || echo 0)" elif command -v python3 &>/dev/null; then SESSION_MEMORIES="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('session_memories',0))" 2>/dev/null || echo 0)" LAST_STORED="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('last_stored',0))" 2>/dev/null || echo 0)" fi fi SESSION_MEMORIES="${SESSION_MEMORIES:-0}" LAST_STORED="${LAST_STORED:-0}" # --- Semver comparison: returns 0 if $1 is strictly newer than $2 --- _version_newer() { local new_ver="$1" cur_ver="$2" [[ "${new_ver}" == "${cur_ver}" ]] && return 1 local IFS='.' local -a na=($new_ver) ca=($cur_ver) for i in 0 1 2; do local n="${na[$i]:-0}" c="${ca[$i]:-0}" n="${n//[^0-9]/}"; n="${n:-0}" c="${c//[^0-9]/}"; c="${c:-0}" (( n > c )) && return 0 (( n < c )) && return 1 done return 1 } # --- Fetch recent activity from API --- ACTIVITY_LABEL="" ACTIVITY_ELAPSED="" LATEST_VERSION="" if [[ -n "${API_KEY}" && -n "${SERVER_URL}" ]] && ! "${MOD_DRAWS}"; then STATUS_JSON="$(curl -sf "${SERVER_URL}/api/status" \ -H "Authorization: Bearer ${API_KEY}" \ --max-time 3 2>/dev/null || true)" if [[ -n "${STATUS_JSON}" ]]; then if command -v jq &>/dev/null; then ACTIVITY_LABEL="$(printf '%s' "${STATUS_JSON}" | jq -r '.data.label // empty' 2>/dev/null || true)" ACTIVITY_ELAPSED="$(printf '%s' "${STATUS_JSON}" | jq -r '.data.elapsed_s // empty' 2>/dev/null || true)" LATEST_VERSION="$(printf '%s' "${STATUS_JSON}" | jq -r '.data.latest_version // empty' 2>/dev/null || true)" elif command -v python3 &>/dev/null; then ACTIVITY_LABEL="$(printf '%s' "${STATUS_JSON}" | python3 -c "import json,sys; d=json.load(sys.stdin); print((d.get('data') or {}).get('label',''),end='')" 2>/dev/null || true)" ACTIVITY_ELAPSED="$(printf '%s' "${STATUS_JSON}" | python3 -c "import json,sys; d=json.load(sys.stdin); print((d.get('data') or {}).get('elapsed_s',''),end='')" 2>/dev/null || true)" LATEST_VERSION="$(printf '%s' "${STATUS_JSON}" | python3 -c "import json,sys; d=json.load(sys.stdin); print((d.get('data') or {}).get('latest_version',''),end='')" 2>/dev/null || true)" fi fi fi # --- Build Recall segment --- RECALL_SEGMENT="Recall: ${VERSION}" if [[ "${SESSION_MEMORIES}" -gt 0 ]] 2>/dev/null; then RECALL_SEGMENT="Recall: ${VERSION} 🧠 ${SESSION_MEMORIES}" fi # Append activity label if recent (< 60 seconds) if [[ -n "${ACTIVITY_LABEL}" && -n "${ACTIVITY_ELAPSED}" ]]; then if [[ "${ACTIVITY_ELAPSED}" =~ ^[0-9]+$ ]] && [[ "${ACTIVITY_ELAPSED}" -lt 60 ]] 2>/dev/null; then if [[ "${ACTIVITY_ELAPSED}" -lt 2 ]]; then RECALL_SEGMENT="${RECALL_SEGMENT} · ${ACTIVITY_LABEL} (just now)" else RECALL_SEGMENT="${RECALL_SEGMENT} · ${ACTIVITY_LABEL} (${ACTIVITY_ELAPSED}s ago)" fi fi fi # Append update indicator if server version is newer than local if [[ -n "${LATEST_VERSION}" ]] && _version_newer "${LATEST_VERSION}" "${VERSION}"; then RECALL_SEGMENT="${RECALL_SEGMENT} · ⬆ ${LATEST_VERSION} — Restart Claude Code to update." fi # --- Collect previous command output (if configured) --- PREV_OUTPUT="" if [[ -n "${PREV_CMD}" ]]; then # Pipe the same stdin JSON that Claude Code sent us into the previous command # so it gets the full session context (model, context window, etc.). # timeout 3: if the previous command hangs, fall through to standalone output. # timeout is not available on vanilla macOS (GNU coreutils required) — fall back to no timeout. if command -v timeout &>/dev/null; then PREV_OUTPUT="$(printf '%s' "${STDIN_DATA}" | timeout 3 bash -c "${PREV_CMD}" 2>/dev/null || true)" else PREV_OUTPUT="$(printf '%s' "${STDIN_DATA}" | bash -c "${PREV_CMD}" 2>/dev/null || true)" fi # Detect if the previous command returned a help/configuration message instead of # real status data. Pilot outputs this when it cannot detect Claude Code session # context (e.g. when called as a subprocess rather than directly by Claude Code). # Discard and fall through to standalone mode below. if printf '%s\n' "${PREV_OUTPUT}" | grep -q "designed to be called by Claude Code"; then PREV_OUTPUT="" fi fi # --- Output --- if [[ -n "${PREV_OUTPUT}" ]]; then # Print previous command output (e.g. Pilot) in full, then Recall on its own line printf '%s\n' "${PREV_OUTPUT}" "${MOD_DRAWS}" || printf '%s\n' "${RECALL_SEGMENT}" else # Standalone mode — Pilot not installed, unavailable, or cannot detect Claude Code # session context. Replicate Pilot's two-line style using the JSON Claude Code sent us. MODEL_NAME="" CONTEXT_PCT="" if command -v jq &>/dev/null; then MODEL_NAME="$(printf '%s' "${STDIN_DATA}" | jq -r '.model.display_name // empty' 2>/dev/null || true)" CONTEXT_PCT="$(printf '%s' "${STDIN_DATA}" | jq -r '.context_window.used_percentage // empty' 2>/dev/null || true)" elif command -v python3 &>/dev/null; then MODEL_NAME="$(printf '%s' "${STDIN_DATA}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('model',{}).get('display_name',''),end='')" 2>/dev/null || true)" CONTEXT_PCT="$(printf '%s' "${STDIN_DATA}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('context_window',{}).get('used_percentage',''),end='')" 2>/dev/null || true)" fi # Line 1: model name + 10-char context bar + percentage if [[ -n "${MODEL_NAME}" ]]; then if [[ -n "${CONTEXT_PCT}" ]] && [[ "${CONTEXT_PCT}" =~ ^[0-9]+$ ]]; then FILLED=$(( CONTEXT_PCT / 10 )) BAR="" for ((i=0; i<10; i++)); do if [[ $i -lt $FILLED ]]; then BAR="${BAR}▓"; else BAR="${BAR}░"; fi done printf '%s\n' "${MODEL_NAME} [${BAR}] ${CONTEXT_PCT}%" else printf '%s\n' "${MODEL_NAME}" fi fi # Line 2: Recall segment, unless the mod shows it above the prompt "${MOD_DRAWS}" || printf '%s\n' "${RECALL_SEGMENT}" fi ENDOFHOOK chmod +x "${HOOKS_DIR}/statusline.sh" cat > "${HOOKS_DIR}/compact-restore.sh" <<'ENDOFHOOK' #!/usr/bin/env bash # Recall compact-restore hook # Fires on SessionStart with matcher: "compact" ONLY. # Re-injects the pre-compaction session state captured by pre-compact.sh so # Claude knows exactly where it left off after the context window was compacted. # Claude Code injects stdout from SessionStart hooks into the conversation. # # It also stores Claude Code's own compact summary as a Recall memory, in the # background, so the hook returns at once and nothing about it is printed. # # Registered (hooks.json, or settings.json for a standalone install) under: # hooks.SessionStart[].hooks[].command # matcher: "compact" # timeout: 5 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load config — silently exit if something goes wrong # shellcheck source=lib/config.sh source "${SCRIPT_DIR}/lib/config.sh" || exit 0 # ─── Store the compact summary ──────────────────────────────────────────────── # After each compact, the session file (transcript_path in the hook input) holds a "system" compact_boundary record, # then a "user" record with isCompactSummary=true: the summary Claude Code wrote. The summary's parentUuid does not # always point at the boundary, so the pair is the newest boundary and the first summary after it in the file. The # boundary's uuid is the key: a marker directory per stored compact, made with mkdir (atomic), so a rerun of this hook # for the same compact stores nothing. A failed POST removes the marker, so the next run tries again. # # Secrets never leave this machine: REDACT_PATTERNS is the same list as observe.sh's and the plugin's hooks/redact.js # (hooks.test.sh compares them), plus .env-style KEY=value lines. If redaction cannot run, nothing is sent. # SHORTCUT: python3 only (no jq path, unlike observe.sh), so a machine without python3 stores no summaries. Add a jq # path if a user without python3 asks for this. REDACT_PATTERNS='[ ["-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----|[\\s\\S]*)", ""], ["(?authorization\\s*[:=]\\s*)(?:(?:bearer|basic|token|digest)\\s+)?[^\\s\"\\x27,;]+", "i"], ["(?\\bbearer\\s+)[A-Za-z0-9._~+/=-]{8,}", "i"], ["(?\\b(?:set-)?cookie[\"\\x27]?[ \\t]*:[ \\t]*)[^\\r\\n\"\\x27]+", "i"], ["(?\\b[a-z][a-z0-9+.-]*://)[^/\\s:@\"\\x27]+:[^/\\s@\"\\x27]+(?=@)", "i"], ["(?\\bhooks\\.slack\\.com/(?:services|workflows|triggers)/)[A-Za-z0-9/_-]+", "i"], ["(?\\b[a-z0-9_.-]*?(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)=[\"\\x27]?)[^\\s\"\\x27&;,)]+", "i"], ["(?\\b[a-z0-9_.-]*?(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)[\"\\x27]?[ \\t]*[:=][ \\t]*[\"\\x27]?)[^\\s\"\\x27&;,)]{6,}", "i"], ["(?\\b[A-Z][A-Z0-9_]*_(?:KEY|PASS|PWD|CREDENTIALS?)=[\"\\x27]?)[^\\s\"\\x27&;,)]+", ""], ["(?(?:^|\\s)--?(?:[a-z0-9]+-)*(?:token|secret|password|passwd|pass|api-?key|access-?key)(?:=|\\s+)[\"\\x27]?)[^\\s\"\\x27]+", "i"], ["(?\\b(?:mysql|mysqldump|mysqladmin|mariadb)\\b[^\\n]*?[ \\t]-p)[^\\s\"\\x27]+", ""], ["\\b(?:sk-ant-|sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|glpat-|ya29\\.|xox[abpr]-|crsr_|ft_live_|ft_test_|ft_run_|rk_live_|rk_test_|sk_live_|sk_test_|whsec_)[A-Za-z0-9_.-]{8,}", ""], ["\\bAKIA[0-9A-Z]{16}\\b", ""], ["\\bAIza[0-9A-Za-z_-]{30,}", ""], ["\\beyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}", ""], ["\\bnpm_[A-Za-z0-9]{30,}", ""] ]' # Prints "". Exit 3: the newest boundary has no summary yet, and it prints the byte # offset of that boundary's line, which the next look passes back in COMPACT_FROM. Any other failure: 1. COMPACT_SUMMARY_PY=' import datetime, json, os, re, sys try: hook = json.loads(os.environ["HOOK_INPUT"]) except Exception: sys.exit(1) if not isinstance(hook, dict): sys.exit(1) path = str(hook.get("transcript_path") or "") if not path or not os.path.isfile(path): sys.exit(1) start = int(os.environ.get("COMPACT_FROM") or 0) boundary, summary, at, pos = None, None, 0, start with open(path, "rb") as f: f.seek(start) for raw in f: here, pos = pos, pos + len(raw) if b"compact_boundary" not in raw and b"isCompactSummary" not in raw: continue try: rec = json.loads(raw.decode("utf-8", "replace")) except Exception: continue if not isinstance(rec, dict): continue if rec.get("type") == "system" and rec.get("subtype") == "compact_boundary": boundary, summary, at = rec, None, here elif rec.get("isCompactSummary") is True and boundary is not None and summary is None: summary = rec if boundary is None: sys.exit(1) key = str(boundary.get("uuid") or "") if not re.fullmatch(r"[A-Za-z0-9-]{1,64}", key): sys.exit(1) if summary is None: sys.stdout.write(str(at)) sys.exit(3) body = (summary.get("message") or {}).get("content") if isinstance(body, list): body = "\n".join(str(b.get("text") or "") for b in body if isinstance(b, dict) and b.get("type") == "text") text = body.strip() if isinstance(body, str) else "" if not text: sys.exit(1) ts = str(boundary.get("timestamp") or "") date = ts[:10] if re.match(r"\d{4}-\d{2}-\d{2}", ts) else datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d") trigger = str((boundary.get("compactMetadata") or {}).get("trigger") or "unknown") session_id = str(hook.get("session_id") or boundary.get("sessionId") or "") session_name = "" try: session_name = str(json.load(open(os.path.expanduser("~/.claude/recall/state.json"))).get("session_name") or "") except Exception: pass workspace = os.environ.get("RECALL_WORKSPACE", "") remote = os.environ.get("RECALL_GIT_REMOTE", "") project = re.sub(r"\.git$", "", (remote or workspace).rstrip("/").rsplit("/", 1)[-1].rsplit(":", 1)[-1]) or "unknown" content = "Compact summary (%s). Session: %s. Project: %s. Date: %s.\n\n%s" % ( trigger, session_name or session_id or "unknown", remote or workspace, date, text[:100000]) # .env-style lines: the value after KEY= goes, the name stays. content = re.sub(r"(?m)^([ \t]*(?:export[ \t]+)?[A-Z_][A-Z0-9_]*[ \t]*=)(?!=)[ \t]*\S.*$", r"\1[REDACTED]", content) for p, fl in json.loads(os.environ["REDACT_PATTERNS"]): content = re.sub(p.replace("(?", "(?P"), lambda m: (m.groupdict().get("k") or "") + "[REDACTED]", content, flags=re.I if "i" in fl else 0) tags = ["auto-hook", "compact-summary", "compact:" + key, "project:" + project, "date:" + date] if re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id): tags.append("session:" + session_id) if workspace: tags.append("workspace:" + workspace) payload = {"content": content, "context_type": "information", "importance": 6, "tags": tags, "is_global": False} sys.stdout.write(key + "\t" + json.dumps(payload)) ' store_compact_summary() { local out="" rc=0 waited=0 from=0 key payload marker status local wait="${RECALL_COMPACT_WAIT_SECONDS:-30}" [[ "${wait}" =~ ^[0-9]+$ ]] || wait=30 while :; do rc=0 out="$(HOOK_INPUT="${HOOK_INPUT}" REDACT_PATTERNS="${REDACT_PATTERNS}" COMPACT_FROM="${from}" \ RECALL_WORKSPACE="${RECALL_WORKSPACE}" RECALL_GIT_REMOTE="${RECALL_GIT_REMOTE}" python3 -c "${COMPACT_SUMMARY_PY}" 2>/dev/null)" || rc=$? # The summary record can land after the boundary. Look again once a second, from the boundary on, for up to # RECALL_COMPACT_WAIT_SECONDS (30). This runs in the background, so the session never waits for it. (( rc == 3 && waited < wait )) || break [[ "${out}" =~ ^[0-9]+$ ]] && from="${out}" waited=$((waited + 1)) sleep 1 done (( rc == 0 )) && [[ "${out}" == *$'\t'* ]] || return 0 key="${out%%$'\t'*}" payload="${out#*$'\t'}" marker="${HOME}/.claude/recall/compact-stored" mkdir -p "${marker}" 2>/dev/null || return 0 # Markers older than 30 days go; a compact that old is never rerun. find "${marker}" -mindepth 1 -maxdepth 1 -type d -mtime +30 -exec rmdir {} + 2>/dev/null || true mkdir "${marker}/${key}" 2>/dev/null || return 0 # The payload goes on stdin, not argv, so the summary never shows in a process list. status="$(printf '%s' "${payload}" | curl \ --silent \ --max-time 10 \ --output /dev/null \ --write-out "%{http_code}" \ --request POST \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ --header "X-Recall-Workspace: ${RECALL_WORKSPACE}" \ --header "X-Recall-Git-Remote: ${RECALL_GIT_REMOTE}" \ --data-binary @- \ "${RECALL_SERVER_URL}/api/memories" \ 2>/dev/null || echo "000")" [[ "${status}" =~ ^2 ]] || rmdir "${marker}/${key}" 2>/dev/null || true } # No API key, no stdin, or no python3: store nothing, and restore as before. HOOK_INPUT="" [[ -t 0 ]] || HOOK_INPUT="$(head -c 65536 2>/dev/null || true)" if [[ -n "${RECALL_API_KEY}" && -n "${HOOK_INPUT}" ]] && command -v python3 &>/dev/null; then ( store_compact_summary ) /dev/null 2>&1 & disown $! 2>/dev/null || true fi PRE_COMPACT_STATE="${HOME}/.claude/recall/pre-compact-state.json" # No saved state — nothing to restore (first compaction or pre-compact not installed) [[ ! -f "${PRE_COMPACT_STATE}" ]] && exit 0 # Read and immediately delete — single-use (one compaction → one restore) STATE_CONTENT="$(cat "${PRE_COMPACT_STATE}" 2>/dev/null || true)" rm -f "${PRE_COMPACT_STATE}" 2>/dev/null || true [[ -z "${STATE_CONTENT}" ]] && exit 0 # ─── Parse saved state ──────────────────────────────────────────────────────── SESSION_NAME="" MEMORIES_COUNT=0 CWD_PATH="" GIT_REMOTE="" if command -v python3 &>/dev/null; then SESSION_NAME="$(echo "${STATE_CONTENT}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('session_name',''))" 2>/dev/null || true)" MEMORIES_COUNT="$(echo "${STATE_CONTENT}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('session_memories',0))" 2>/dev/null || echo 0)" CWD_PATH="$(echo "${STATE_CONTENT}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || true)" GIT_REMOTE="$(echo "${STATE_CONTENT}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('git_remote',''))" 2>/dev/null || true)" elif command -v jq &>/dev/null; then SESSION_NAME="$(echo "${STATE_CONTENT}" | jq -r '.session_name // empty' 2>/dev/null || true)" MEMORIES_COUNT="$(echo "${STATE_CONTENT}" | jq -r '.session_memories // 0' 2>/dev/null || echo 0)" CWD_PATH="$(echo "${STATE_CONTENT}" | jq -r '.cwd // empty' 2>/dev/null || true)" GIT_REMOTE="$(echo "${STATE_CONTENT}" | jq -r '.git_remote // empty' 2>/dev/null || true)" fi MEMORIES_COUNT="${MEMORIES_COUNT:-0}" # ─── Print restore context to stdout ───────────────────────────────────────── # Claude Code injects stdout from SessionStart hooks into the conversation context. echo "> 🧠 [Recall] Context restored after compaction" echo ">" [[ -n "${SESSION_NAME}" ]] && echo "> Session: ${SESSION_NAME}" [[ -n "${GIT_REMOTE}" ]] && echo "> Project: ${GIT_REMOTE}" [[ -n "${CWD_PATH}" ]] && echo "> Working directory: ${CWD_PATH}" (( MEMORIES_COUNT > 0 )) && echo "> Memories stored this session so far: ${MEMORIES_COUNT}" echo ">" echo "> Continue from where you left off — your memory context has been preserved." ENDOFHOOK chmod +x "${HOOKS_DIR}/compact-restore.sh" cat > "${HOOKS_DIR}/stop-summarize.sh" <<'ENDOFHOOK' #!/usr/bin/env bash # Recall stop-summarize hook # Stores a session summary memory so the next session has an immediate # "what was last done here" reference. # # Registered in settings.json under: # hooks.SessionEnd[].hooks[].command # async: true (must not delay exit) # timeout: 10 # # History: this used to fire on Stop, which is every TURN end - so a long # session stored dozens of near-identical "Session ended" memories, each at # importance 7 and therefore each carrying an ~11KB embedding. It now fires # on SessionEnd at importance 3 (below the embedding threshold), and the # summarized_memories guard below keeps even a stale Stop registration from # storing more than once per change. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load config - silently exit if something goes wrong # shellcheck source=lib/config.sh source "${SCRIPT_DIR}/lib/config.sh" || exit 0 # No API key - nothing to do [[ -z "${RECALL_API_KEY}" ]] && exit 0 STATE_FILE="${HOME}/.claude/recall/state.json" [[ ! -f "${STATE_FILE}" ]] && exit 0 # --- Read session state ------------------------------------------------------- SESSION_NAME="" SESSION_MEMORIES=0 LAST_STORED=0 SUMMARIZED=0 if command -v python3 &>/dev/null; then SESSION_NAME="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('session_name',''))" 2>/dev/null || true)" SESSION_MEMORIES="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('session_memories',0))" 2>/dev/null || echo 0)" LAST_STORED="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('last_stored',0))" 2>/dev/null || echo 0)" SUMMARIZED="$(python3 -c "import json; d=json.load(open('${STATE_FILE}')); print(d.get('summarized_memories',0))" 2>/dev/null || echo 0)" elif command -v jq &>/dev/null; then SESSION_NAME="$(jq -r '.session_name // empty' "${STATE_FILE}" 2>/dev/null || true)" SESSION_MEMORIES="$(jq -r '.session_memories // 0' "${STATE_FILE}" 2>/dev/null || echo 0)" LAST_STORED="$(jq -r '.last_stored // 0' "${STATE_FILE}" 2>/dev/null || echo 0)" SUMMARIZED="$(jq -r '.summarized_memories // 0' "${STATE_FILE}" 2>/dev/null || echo 0)" fi SESSION_MEMORIES="${SESSION_MEMORIES:-0}" SUMMARIZED="${SUMMARIZED:-0}" # Nothing was stored this session - no meaningful summary to create [[ "${SESSION_MEMORIES}" -eq 0 ]] && exit 0 # Already summarized this exact count - a repeat firing (e.g. a stale Stop # registration running every turn) has nothing new to say. [[ "${SESSION_MEMORIES}" -eq "${SUMMARIZED}" ]] && exit 0 # --- Build summary content ---------------------------------------------------- NOW="$(date +%s 2>/dev/null || echo 0)" SESSION_LABEL="${SESSION_NAME:-unknown}" PROJECT_LABEL="${RECALL_GIT_REMOTE:-${RECALL_WORKSPACE}}" CONTENT="Session ended: ${SESSION_LABEL}. Project: ${PROJECT_LABEL}. Stored ${SESSION_MEMORIES} memories this session." if (( LAST_STORED > 0 && NOW > 0 )); then ELAPSED=$(( NOW - LAST_STORED )) if (( ELAPSED < 3600 )); then CONTENT="${CONTENT} Last activity ${ELAPSED}s before exit." fi fi # --- Build JSON payload ------------------------------------------------------- # Importance 3: a bookkeeping marker, deliberately below the embedding # threshold (4). Nobody semantically searches for "Session ended". if command -v jq &>/dev/null; then PAYLOAD="$(jq -n \ --arg content "${CONTENT}" \ '{content: $content, context_type: "information", importance: 3, tags: ["session-summary", "auto-hook"], is_global: false}')" elif command -v python3 &>/dev/null; then PAYLOAD="$(CONTENT="${CONTENT}" python3 -c " import json, os print(json.dumps({ 'content': os.environ['CONTENT'], 'context_type': 'information', 'importance': 3, 'tags': ['session-summary', 'auto-hook'], 'is_global': False }))")" else ESCAPED="${CONTENT//\"/\\\"}" PAYLOAD="{\"content\":\"${ESCAPED}\",\"context_type\":\"information\",\"importance\":3,\"tags\":[\"session-summary\",\"auto-hook\"],\"is_global\":false}" fi # --- POST, then record what was summarized ------------------------------------ HTTP_STATUS="$(curl \ --silent \ --max-time 5 \ --output /dev/null \ --write-out "%{http_code}" \ --request POST \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${RECALL_API_KEY}" \ --header "X-Recall-Workspace: ${RECALL_WORKSPACE}" \ --header "X-Recall-Git-Remote: ${RECALL_GIT_REMOTE}" \ --data "${PAYLOAD}" \ "${RECALL_SERVER_URL}/api/memories" \ 2>/dev/null || echo "000")" if [[ "${HTTP_STATUS}" =~ ^2 ]] && command -v python3 &>/dev/null; then SESSION_MEMORIES="${SESSION_MEMORIES}" STATE_FILE="${STATE_FILE}" python3 -c " import json, os sf = os.environ['STATE_FILE'] try: d = json.load(open(sf)) except Exception: d = {} d['summarized_memories'] = int(os.environ['SESSION_MEMORIES']) json.dump(d, open(sf, 'w')) " 2>/dev/null || true fi ENDOFHOOK chmod +x "${HOOKS_DIR}/stop-summarize.sh" } echo "" echo -e "${GREEN} Installing Recall for Claude Code ...${NC}" echo "" info "Writing hook scripts ..." if [[ -n "${SCRIPT_DIR}" ]]; then # Running from local disk — copy from source directory write_hook_from_file "${HOOKS_DIR}/lib/config.sh" "lib/config.sh" write_hook_from_file "${HOOKS_DIR}/session-start.sh" "session-start.sh" write_hook_from_file "${HOOKS_DIR}/compact-restore.sh" "compact-restore.sh" write_hook_from_file "${HOOKS_DIR}/observe.sh" "observe.sh" write_hook_from_file "${HOOKS_DIR}/pre-compact.sh" "pre-compact.sh" write_hook_from_file "${HOOKS_DIR}/session-end.sh" "session-end.sh" write_hook_from_file "${HOOKS_DIR}/stop-summarize.sh" "stop-summarize.sh" write_hook_from_file "${HOOKS_DIR}/stop.sh" "stop.sh" write_hook_from_file "${HOOKS_DIR}/statusline.sh" "statusline.sh" else # Running via curl | bash — write from embedded heredocs write_embedded_hooks fi # Remove hook files installed by previous versions rm -f "${HOOKS_DIR}/user-prompt-submit.sh" # ─── Validate API key ───────────────────────────────────────────────────────── info "Validating API key against ${SERVER_URL} ..." # Validate against an authenticated endpoint — /health requires no auth and would pass any key HTTP_STATUS="$(curl --silent --max-time 10 --output /dev/null \ --write-out '%{http_code}' \ --header "Authorization: Bearer ${API_KEY}" \ --header "X-Recall-Workspace: $(pwd)" \ "${SERVER_URL}/api/context?limit=1" 2>/dev/null || true)" if [[ "${HTTP_STATUS}" == "401" ]]; then error "Invalid API key (HTTP 401). Check your key at https://recallmcp.com/dashboard" exit 1 elif [[ "${HTTP_STATUS}" != "200" ]]; then warn "Could not verify API key (HTTP ${HTTP_STATUS:-000}). Continuing anyway." warn "Check your API key at https://recallmcp.com/dashboard if hooks don't work." fi # ─── Capture existing statusLine command (before we overwrite it) ───────────── # If there's already a non-Recall statusLine command, save it so our wrapper # can chain to it, and so uninstall can restore it. PREV_STATUSLINE_CMD="" if [[ -f "${CLAUDE_SETTINGS}" ]]; then if command -v python3 &>/dev/null; then PREV_STATUSLINE_CMD="$(python3 -c "import json; d=json.load(open('${CLAUDE_SETTINGS}')); print((d.get('statusLine') or {}).get('command',''),end='')" 2>/dev/null || true)" elif command -v jq &>/dev/null; then PREV_STATUSLINE_CMD="$(jq -r '(.statusLine.command // "")' "${CLAUDE_SETTINGS}" 2>/dev/null || true)" fi fi # If the current statusLine is already our wrapper, read the ORIGINAL previous # command from the existing config.json instead (idempotency: don't save our # own wrapper as the "previous" command). if [[ "${PREV_STATUSLINE_CMD}" == *"recall"* && "${PREV_STATUSLINE_CMD}" == *"statusline"* ]]; then if [[ -f "${CONFIG_FILE}" ]]; then if command -v python3 &>/dev/null; then PREV_STATUSLINE_CMD="$(python3 -c "import json; d=json.load(open('${CONFIG_FILE}')); print(d.get('previous_statusline_cmd',''),end='')" 2>/dev/null || true)" elif command -v jq &>/dev/null; then PREV_STATUSLINE_CMD="$(jq -r '.previous_statusline_cmd // empty' "${CONFIG_FILE}" 2>/dev/null || true)" fi else PREV_STATUSLINE_CMD="" fi fi # ─── Write config.json ──────────────────────────────────────────────────────── info "Writing ${CONFIG_FILE} ..." chmod 700 "${INSTALL_DIR}" if command -v jq &>/dev/null; then jq -n \ --arg api_key "${API_KEY}" \ --arg server_url "${SERVER_URL}" \ --arg installed_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --arg version "${VERSION}" \ --arg prev_sl "${PREV_STATUSLINE_CMD}" \ '{api_key: $api_key, server_url: $server_url, installed_at: $installed_at, version: $version, previous_statusline_cmd: $prev_sl}' \ > "${CONFIG_FILE}" elif command -v python3 &>/dev/null; then API_KEY="${API_KEY}" SERVER_URL="${SERVER_URL}" VERSION="${VERSION}" PREV_SL="${PREV_STATUSLINE_CMD}" python3 -c " import json, datetime, os data = { 'api_key': os.environ['API_KEY'], 'server_url': os.environ['SERVER_URL'], 'installed_at': datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), 'version': os.environ['VERSION'], 'previous_statusline_cmd': os.environ.get('PREV_SL', ''), } print(json.dumps(data, indent=2)) " > "${CONFIG_FILE}" else cat > "${CONFIG_FILE}" </dev/null || echo "unknown")", "version": "${VERSION}", "previous_statusline_cmd": "${PREV_STATUSLINE_CMD}" } EOF fi chmod 600 "${CONFIG_FILE}" info "Config written (permissions: 600)" # ─── Patch ~/.claude/settings.json ─────────────────────────────────────────── patch_settings() { local settings_file="$1" # Create settings file if it doesn't exist if [[ ! -f "${settings_file}" ]]; then echo '{}' > "${settings_file}" fi # Back up before patching cp "${settings_file}" "${settings_file}.recall-backup" info "Backed up settings.json → settings.json.recall-backup" # The hook entries we want to add local session_start_entry local post_tool_use_entry local pre_compact_entry local compact_restore_entry local stop_summarize_entry local stop_entry local stop_event_entry session_start_entry="{\"matcher\":\"startup|resume|clear|compact\",\"hooks\":[{\"type\":\"command\",\"command\":\"bash \\\"${HOOKS_DIR}/session-start.sh\\\"\",\"timeout\":10}]}" compact_restore_entry="{\"matcher\":\"compact\",\"hooks\":[{\"type\":\"command\",\"command\":\"bash \\\"${HOOKS_DIR}/compact-restore.sh\\\"\",\"timeout\":5}]}" post_tool_use_entry="{\"matcher\":\"Bash\",\"hooks\":[{\"type\":\"command\",\"command\":\"bash \\\"${HOOKS_DIR}/observe.sh\\\"\",\"async\":true,\"timeout\":10}]}" pre_compact_entry="{\"hooks\":[{\"type\":\"command\",\"command\":\"bash \\\"${HOOKS_DIR}/pre-compact.sh\\\"\",\"timeout\":10}]}" stop_summarize_entry="{\"hooks\":[{\"type\":\"command\",\"command\":\"bash \\\"${HOOKS_DIR}/stop-summarize.sh\\\"\",\"async\":true,\"timeout\":10}]}" stop_entry="{\"hooks\":[{\"type\":\"command\",\"command\":\"bash \\\"${HOOKS_DIR}/session-end.sh\\\"\",\"async\":true,\"timeout\":10}]}" stop_event_entry="{\"hooks\":[{\"type\":\"command\",\"command\":\"bash \\\"${HOOKS_DIR}/stop.sh\\\"\",\"timeout\":5}]}" if command -v node &>/dev/null; then # Use node as JSON merger (guaranteed on dev machines) node -e " const fs = require('fs'); const settingsFile = '${settings_file}'; const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); const recallMarker = '${HOOKS_DIR}'; function hasRecall(arr) { if (!Array.isArray(arr)) return false; return arr.some(e => JSON.stringify(e).includes(recallMarker)); } // Per-script check: does a specific recall hook script appear in the array? function hasRecallHook(arr, scriptName) { if (!Array.isArray(arr)) return false; return arr.some(e => JSON.stringify(e).includes(recallMarker) && JSON.stringify(e).includes(scriptName)); } if (!settings.hooks) settings.hooks = {}; // Plugin-managed detection: when the Recall plugin's own hooks manifest // exists next to the scripts dir, Claude Code already fires every Recall // hook from it. Legacy installs ALSO wrote the same hooks into // settings.json, so every tool event and session end fired twice and // stored duplicate memories. The manifest is the single source of truth. const pluginHooksManifest = require('path').join(recallMarker, '..', 'hooks', 'hooks.json'); const pluginManaged = fs.existsSync(pluginHooksManifest); if (pluginManaged) { // Strip every Recall hook entry from settings.json - the plugin fires them. for (const eventName of Object.keys(settings.hooks)) { if (!Array.isArray(settings.hooks[eventName])) continue; settings.hooks[eventName] = settings.hooks[eventName].filter(e => !JSON.stringify(e).includes(recallMarker)); if (settings.hooks[eventName].length === 0) delete settings.hooks[eventName]; } console.log('Plugin-managed hooks detected: settings.json Recall hook entries removed (plugin manifest fires them)'); } else { // SessionStart if (!hasRecall(settings.hooks.SessionStart)) { settings.hooks.SessionStart = [...(settings.hooks.SessionStart || []), ${session_start_entry}]; } // SessionStart: compact-restore (targeted post-compaction restore) if (!hasRecallHook(settings.hooks.SessionStart, 'compact-restore.sh')) { settings.hooks.SessionStart = [...(settings.hooks.SessionStart || []), ${compact_restore_entry}]; } // PostToolUse if (!hasRecall(settings.hooks.PostToolUse)) { settings.hooks.PostToolUse = [...(settings.hooks.PostToolUse || []), ${post_tool_use_entry}]; } // PreCompact if (!hasRecall(settings.hooks.PreCompact)) { settings.hooks.PreCompact = [...(settings.hooks.PreCompact || []), ${pre_compact_entry}]; } // Stop: session-end.sh (async, marks session end) if (!hasRecallHook(settings.hooks.Stop, 'session-end.sh')) { settings.hooks.Stop = [...(settings.hooks.Stop || []), ${stop_entry}]; } // SessionEnd: stop-summarize.sh (async session summary, once per session). // It used to fire on Stop, i.e. every TURN end - migrate old registrations. if (Array.isArray(settings.hooks.Stop)) { settings.hooks.Stop = settings.hooks.Stop.filter(e => !(JSON.stringify(e).includes(recallMarker) && JSON.stringify(e).includes('stop-summarize.sh'))); } if (!hasRecallHook(settings.hooks.SessionEnd, 'stop-summarize.sh')) { settings.hooks.SessionEnd = [...(settings.hooks.SessionEnd || []), ${stop_summarize_entry}]; } // Stop: stop.sh (synchronous event poller - can return block decision) if (!hasRecallHook(settings.hooks.Stop, 'stop.sh')) { settings.hooks.Stop = [...(settings.hooks.Stop || []), ${stop_event_entry}]; } } // UserPromptSubmit: remove Recall entry if previously installed (v1.11.11 experiment removed) if (Array.isArray(settings.hooks.UserPromptSubmit)) { settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(e => !JSON.stringify(e).includes(recallMarker)); if (settings.hooks.UserPromptSubmit.length === 0) delete settings.hooks.UserPromptSubmit; } // statusLine: Set Recall's wrapper as the statusLine command. // The wrapper chains to the previous command (if any) and appends Recall status. const recallStatusLine = '${HOOKS_DIR}/statusline.sh'; const currentSlCmd = (settings.statusLine || {}).command || ''; if (!currentSlCmd.includes(recallStatusLine)) { settings.statusLine = { type: 'command', command: 'bash \"' + recallStatusLine + '\"', padding: 0 }; } fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n'); console.log('Settings patched successfully'); " elif command -v python3 &>/dev/null; then python3 -c " import json, sys settings_file = '${settings_file}' hooks_dir = '${HOOKS_DIR}' with open(settings_file) as f: settings = json.load(f) def has_recall(arr): if not isinstance(arr, list): return False return any(hooks_dir in json.dumps(e) for e in arr) def has_recall_hook(arr, script_name): if not isinstance(arr, list): return False return any(hooks_dir in json.dumps(e) and script_name in json.dumps(e) for e in arr) if 'hooks' not in settings: settings['hooks'] = {} h = settings['hooks'] # Plugin-managed detection: when the plugin's hooks manifest exists, Claude # Code already fires every Recall hook from it. Legacy installs also wrote # them into settings.json, so every event fired twice and stored duplicates. import os plugin_managed = os.path.exists(os.path.join(hooks_dir, '..', 'hooks', 'hooks.json')) if plugin_managed: # Strip every Recall hook entry - the plugin manifest fires them. for event_name in list(h.keys()): if not isinstance(h[event_name], list): continue h[event_name] = [e for e in h[event_name] if hooks_dir not in json.dumps(e)] if not h[event_name]: del h[event_name] print('Plugin-managed hooks detected: settings.json Recall hook entries removed (plugin manifest fires them)') else: if not has_recall(h.get('SessionStart')): h.setdefault('SessionStart', []).append(json.loads('${session_start_entry}')) # SessionStart: compact-restore (targeted post-compaction restore) if not has_recall_hook(h.get('SessionStart'), 'compact-restore.sh'): h.setdefault('SessionStart', []).append(json.loads('${compact_restore_entry}')) if not has_recall(h.get('PostToolUse')): h.setdefault('PostToolUse', []).append(json.loads('${post_tool_use_entry}')) if not has_recall(h.get('PreCompact')): h.setdefault('PreCompact', []).append(json.loads('${pre_compact_entry}')) # Stop: session-end.sh (async, marks session end) if not has_recall_hook(h.get('Stop'), 'session-end.sh'): h.setdefault('Stop', []).append(json.loads('${stop_entry}')) # SessionEnd: stop-summarize.sh (async session summary, once per session). # It used to fire on Stop, i.e. every TURN end - migrate old registrations. if isinstance(h.get('Stop'), list): h['Stop'] = [e for e in h['Stop'] if not (hooks_dir in json.dumps(e) and 'stop-summarize.sh' in json.dumps(e))] if not has_recall_hook(h.get('SessionEnd'), 'stop-summarize.sh'): h.setdefault('SessionEnd', []).append(json.loads('${stop_summarize_entry}')) # Stop: stop.sh (synchronous event poller - can return block decision) if not has_recall_hook(h.get('Stop'), 'stop.sh'): h.setdefault('Stop', []).append(json.loads('${stop_event_entry}')) # UserPromptSubmit: remove Recall entry if previously installed (v1.11.11 experiment removed) if isinstance(h.get('UserPromptSubmit'), list): h['UserPromptSubmit'] = [e for e in h['UserPromptSubmit'] if hooks_dir not in json.dumps(e)] if not h['UserPromptSubmit']: del h['UserPromptSubmit'] # statusLine: Set Recall's wrapper as the statusLine command. # The wrapper chains to the previous command (if any) and appends Recall status. recall_statusline = '${HOOKS_DIR}/statusline.sh' current_sl_cmd = (settings.get('statusLine') or {}).get('command', '') if recall_statusline not in current_sl_cmd: settings['statusLine'] = {'type': 'command', 'command': 'bash \"' + recall_statusline + '\"', 'padding': 0} with open(settings_file, 'w') as f: json.dump(settings, f, indent=2) f.write('\n') print('Settings patched successfully') " else warn "Neither node nor python3 found — cannot patch settings.json automatically." warn "Add the following to ${settings_file} manually:" warn " hooks.SessionStart: ${session_start_entry}" warn " hooks.SessionStart (compact-restore): ${compact_restore_entry}" warn " hooks.PostToolUse: ${post_tool_use_entry}" warn " hooks.PreCompact: ${pre_compact_entry}" warn " hooks.SessionEnd (session-summary): ${stop_summarize_entry}" warn " hooks.Stop (session-end): ${stop_entry}" warn " hooks.Stop (event poller): ${stop_event_entry}" return 1 fi } info "Patching ${CLAUDE_SETTINGS} ..." if patch_settings "${CLAUDE_SETTINGS}"; then info "settings.json patched successfully" else warn "Manual settings.json patch required (see above)" fi # ─── Done ───────────────────────────────────────────────────────────────────── echo "" echo -e "${GREEN}┌──────────────────────────────────────────────────────────┐${NC}" echo -e "${GREEN}│${NC} ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ${GREEN}✓ Recall installed successfully!${NC} ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ${GREEN}What was installed:${NC} ${GREEN}│${NC}" echo -e "${GREEN}│${NC} MCP Server Connected to recallmcp.com ${GREEN}│${NC}" echo -e "${GREEN}│${NC} Auto-Memory Hooks store errors + session summary ${GREEN}│${NC}" echo -e "${GREEN}│${NC} Event System Webhooks routed to your sessions ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ${GREEN}Hooks registered:${NC} ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ▶ SessionStart Load context + restore post-compaction ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ✎ PostToolUse Capture failing Bash commands ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ⧖ PreCompact Save state before context compaction ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ■ Stop Poll for events ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ■ SessionEnd Session summary once per session ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ${GREEN}│${NC}" echo -e "${GREEN}└──────────────────────────────────────────────────────────┘${NC}" echo "" echo " Config: ${CONFIG_FILE}" echo " Hooks: ${HOOKS_DIR}/" echo " Settings backup: ${CLAUDE_SETTINGS}.recall-backup" echo "" echo -e " ${GREEN}To uninstall:${NC} curl -fsSL https://recallmcp.com/uninstall-hooks | bash" echo "" echo -e " ${YELLOW}Start a new Claude Code session to activate.${NC}" echo ""