basemind 0.7.0

Full AI context layer over MCP — tree-sitter code-map, document RAG (PDF/Office/HTML/email + OCR + reranker), shared agent memory, on-demand web crawl, git history + blame + per-symbol diff. 300+ languages, 10+ coding-agent harnesses, content-addressed Fjall + LanceDB.
#!/usr/bin/env bash
# basemind PostToolUse read-cache delta.
#
# Fires after Read tool calls (matcher "Read" in hooks.json). When an agent
# RE-READS a file it already read this session, this hook replaces the full file
# body the agent would see with a compact `basemind delta` (+A/-R line-diff),
# saving the tokens of a full re-read. The cache is per-session and keyed by the
# absolute file path; the CALLER (this hook) owns the cache — `basemind delta`
# itself is stateless.
#
# OPT-IN, default OFF — gated on BASEMIND_DELTA_READS, mirroring the
# BASEMIND_COMPRESS_OUTPUT / BASEMIND_GUARD convention used by the sibling hooks:
#
#   unset / off / 0   (default) — fast no-op passthrough; the full read stands.
#   1 / on / true     — active; serve a delta on a re-read of a cached path.
#
# Safety: this wrapper NEVER errors out the tool call. On any missing dependency,
# unreadable payload, missing session id, empty content, delta failure, or a diff
# that is not meaningfully smaller, it exits 0 with no output, leaving the
# original Read result untouched (passthrough).
#
# Output protocol: a PostToolUse JSON result on stdout whose
# hookSpecificOutput.updatedToolOutput replaces the tool result string. We only
# emit a replacement on a cache HIT that yields a smaller, real diff; otherwise we
# stay silent (exit 0) so the original output is preserved verbatim.
set -euo pipefail

# Gate: anything other than an explicit on-value is a fast no-op passthrough.
MODE="${BASEMIND_DELTA_READS:-off}"
case "${MODE}" in
1 | on | On | ON | true | True | TRUE | yes | Yes | YES) ;;
*) exit 0 ;;
esac

# Need jq to read the event payload + emit well-formed JSON; degrade to a
# passthrough no-op if absent.
command -v jq >/dev/null 2>&1 || exit 0

input="$(cat 2>/dev/null || true)"
[ -n "${input}" ] || exit 0

tool="$(printf '%s' "${input}" | jq -r '.tool_name // empty' 2>/dev/null || true)"
[ "${tool}" = "Read" ] || exit 0

# Pull the file path the agent read and the content the Read tool returned. The
# content lives at .tool_response.content (a string); fall back to
# .tool_response.file.content defensively in case of an object-shaped result.
file_path="$(printf '%s' "${input}" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)"
[ -n "${file_path}" ] || exit 0

content="$(printf '%s' "${input}" |
  jq -r '(.tool_response.content // .tool_response.file.content // "") |
         if type == "string" then . else "" end' 2>/dev/null || true)"
[ -n "${content}" ] || exit 0

# Session id keys the cache namespace. No session ⇒ nothing to key on ⇒ no-op.
session="$(printf '%s' "${input}" | jq -r '.session_id // empty' 2>/dev/null || true)"
[ -n "${session}" ] || exit 0

# Sanitize the session id before building a path from it — it is untrusted input,
# and a value containing "/" or ".." would otherwise let the cache dir escape.
# Collapse everything outside [A-Za-z0-9._-] to "_".
safe_session="$(printf '%s' "${session}" | tr -c 'A-Za-z0-9._-' '_')"
[ -n "${safe_session}" ] || exit 0

# Per-session cache dir under the managed cache root. Each file is keyed by a hash
# of its absolute path so arbitrary paths map to a flat, collision-resistant name.
CACHE_ROOT="${XDG_CACHE_HOME:-${HOME}/.cache}/basemind/read-cache"
SESSION_DIR="${CACHE_ROOT}/${safe_session}"
mkdir -p "${SESSION_DIR}" 2>/dev/null || exit 0

# Cheap hygiene: prune session dirs untouched for 7+ days so the cache root does
# not grow without bound. Fail-open — never let pruning break the read.
find "${CACHE_ROOT}" -mindepth 1 -maxdepth 1 -type d -mtime +7 \
  -exec rm -rf {} + 2>/dev/null || true

# Hash the absolute path to a stable key. Prefer shasum; fall back to sha256sum.
hash_path() {
  if command -v shasum >/dev/null 2>&1; then
    printf '%s' "$1" | shasum -a 256 2>/dev/null | cut -d' ' -f1
  elif command -v sha256sum >/dev/null 2>&1; then
    printf '%s' "$1" | sha256sum 2>/dev/null | cut -d' ' -f1
  fi
}

key="$(hash_path "${file_path}" || true)"
[ -n "${key}" ] || exit 0
entry="${SESSION_DIR}/${key}"

# First sight of this path this session: cache the content, emit nothing. The full
# read stands. Write atomically (temp + mv) so a concurrent read never sees a torn
# entry.
if [ ! -f "${entry}" ]; then
  tmp="${entry}.$$.tmp"
  printf '%s' "${content}" >"${tmp}" 2>/dev/null && mv -f "${tmp}" "${entry}" 2>/dev/null || true
  exit 0
fi

# Resolve the basemind binary the same way the launcher / compress-output hook
# does: explicit override, then the managed per-user version cache, then the
# pre-seeded plugin bin/, then PATH. We never trigger a network install here
# (PostToolUse is on the hot path) — if no local binary is found, pass through.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"

BINARY_NAME="basemind"
case "$(uname -s)" in
MINGW* | MSYS* | CYGWIN* | Windows_NT) BINARY_NAME="basemind.exe" ;;
esac

resolve_bin() {
  if [ -n "${BASEMIND_BIN:-}" ] && [ -x "${BASEMIND_BIN}" ]; then
    printf '%s' "${BASEMIND_BIN}"
    return 0
  fi
  local cache_root="${XDG_CACHE_HOME:-${HOME}/.cache}/basemind/bin"
  if [ -d "${cache_root}" ]; then
    local d v cand=""
    for d in $(printf '%s\n' "${cache_root}"/*/ 2>/dev/null | sort -r); do
      v="${d%/}"
      if [ -x "${v}/${BINARY_NAME}" ]; then
        cand="${v}/${BINARY_NAME}"
        break
      fi
    done
    if [ -n "${cand}" ]; then
      printf '%s' "${cand}"
      return 0
    fi
  fi
  if [ -x "${PLUGIN_ROOT}/bin/${BINARY_NAME}" ]; then
    printf '%s' "${PLUGIN_ROOT}/bin/${BINARY_NAME}"
    return 0
  fi
  if command -v "${BINARY_NAME}" >/dev/null 2>&1; then
    command -v "${BINARY_NAME}"
    return 0
  fi
  return 1
}

# Update the cache entry to the now-current content regardless of the outcome
# below, so the NEXT re-read diffs against what the agent just saw. We snapshot the
# OLD entry first because `basemind delta --old` needs it.
old_entry="${entry}.old.$$"
cp -f "${entry}" "${old_entry}" 2>/dev/null || exit 0
tmp="${entry}.$$.tmp"
printf '%s' "${content}" >"${tmp}" 2>/dev/null && mv -f "${tmp}" "${entry}" 2>/dev/null || true

# shellcheck disable=SC2329  # invoked indirectly via the EXIT trap below.
cleanup() { rm -f "${old_entry}" 2>/dev/null || true; }
trap cleanup EXIT

BIN="$(resolve_bin || true)"
[ -n "${BIN}" ] || exit 0

# Compute the delta: NEW content on stdin, OLD content from the snapshot. The CLI
# is itself fail-open; guard the wrapper too. An empty result ⇒ passthrough.
diff_out="$(printf '%s' "${content}" | "${BIN}" delta --old "${old_entry}" 2>/dev/null || true)"
[ -n "${diff_out}" ] || exit 0

# Bail / unchanged cases are NOT a win — leave the original read alone:
#   "# unchanged"                                   — no change; full read is fine.
#   "# file too large for delta; full content ..."  — delta gave up; full content.
first_line="${diff_out%%$'\n'*}"
case "${first_line}" in
"# unchanged") exit 0 ;;
"# file too large for delta"*) exit 0 ;;
esac

# Only replace when the delta is meaningfully smaller than the full content the
# agent would otherwise see. Require at least a 10% reduction to avoid churning the
# output for a near-total rewrite. Equal-or-larger ⇒ stay silent (passthrough).
content_len="${#content}"
diff_len="${#diff_out}"
[ "${content_len}" -gt 0 ] || exit 0
# diff_len < content_len * 0.9  ⇔  diff_len * 10 < content_len * 9
if [ "$((diff_len * 10))" -ge "$((content_len * 9))" ]; then
  exit 0
fi

# Emit the replacement. A one-line header marks this as a delta vs the previous
# read of the same path, then the compact diff. updatedToolOutput is the string
# the agent sees in place of the full re-read; suppressOutput hides hook chatter.
header="# basemind: delta vs previous read of ${file_path} (re-read; full body suppressed)"
payload="$(printf '%s\n%s' "${header}" "${diff_out}")"
jq -cn --arg out "${payload}" \
  '{suppressOutput:true,hookSpecificOutput:{hookEventName:"PostToolUse",updatedToolOutput:$out}}'
exit 0