diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fbf0b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +### macOS +# Finder metadata +.DS_Store + +# Thumbnails +._* + +# Custom folder icons +Icon + +# Volume root files +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent \ No newline at end of file diff --git a/hooks/sync-memories.sh b/hooks/sync-memories.sh index 0cc1c85..da5d29f 100755 --- a/hooks/sync-memories.sh +++ b/hooks/sync-memories.sh @@ -1,59 +1,108 @@ #!/bin/bash # Hook: index .claude/memories/ into docs-mcp-server for semantic search. -# Runs on SessionStart and UserPromptSubmit (async). Never fails the hook. +# Runs on SessionStart and UserPromptSubmit (async). Never fails the hook and +# never writes to stdout (UserPromptSubmit stdout would be injected into context). set -uo pipefail -# Consume stdin (hook passes JSON context) -cat >/dev/null 2>&1 || true +# Read the hook's stdin JSON so we can fall back to its `cwd` field below. +hook_input=$(cat 2>/dev/null || true) +hook_event=$(printf '%s' "$hook_input" | jq -r '.hook_event_name // empty' 2>/dev/null) -project_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 +# --- Resolve the project root: the directory that contains .claude/ --- +# In a git repo, anchor to the repo toplevel (original behavior): the same repo +# may be checked out under different folder names, and the repo root is the stable +# anchor so they map to one library. For a non-git folder (git rev-parse exits +# non-zero), fall back to where Claude was launched — CLAUDE_PROJECT_DIR, then the +# hook's stdin `cwd`, then $PWD. +project_root=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -z "$project_root" ]; then + cwd=$(printf '%s' "$hook_input" | jq -r '.cwd // empty' 2>/dev/null) + project_root="${CLAUDE_PROJECT_DIR:-${cwd:-$PWD}}" +fi MEMORIES_DIR="$project_root/.claude/memories" TIMESTAMP_FILE="$project_root/.claude/.memories-last-indexed" +LOG_FILE="$project_root/.claude/.memories-index.log" -# Exit early if no memories directory +# Nothing to do without a memories directory that actually holds .md files. [ -d "$MEMORIES_DIR" ] || exit 0 +[ -n "$(find "$MEMORIES_DIR" -name '*.md' -print -quit 2>/dev/null)" ] || exit 0 -# Exit early if Ollama is not running +# Exit early if Ollama is not running. curl -s --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1 || exit 0 -# --- Staleness check --- -# If timestamp file exists and nothing changed, nothing to do. -# If timestamp file doesn't exist, this is the first run — do a full index. -if [ -f "$TIMESTAMP_FILE" ]; then - newest=$(find "$MEMORIES_DIR" -name "*.md" -newer "$TIMESTAMP_FILE" -print -quit 2>/dev/null) - # Also check if the directory itself was modified (file added/removed) - dir_changed="" - [ "$MEMORIES_DIR" -nt "$TIMESTAMP_FILE" ] && dir_changed="yes" - [ -n "$newest" ] || [ -n "$dir_changed" ] || exit 0 +# Start each session with a fresh log so it can't grow unbounded across sessions; +# UserPromptSubmit runs during the session then append to it. Logging is +# best-effort — a failed write here must never stop indexing. +if [ "$hook_event" = "SessionStart" ]; then + : 2>/dev/null >"$LOG_FILE" || true fi -# --- Index --- +log() { + printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$1" 2>/dev/null >>"$LOG_FILE" || true +} + repo_name=$(basename "$project_root") +memories_url="file://$MEMORIES_DIR" +embedding_model="openai:nomic-embed-text" export OPENAI_API_KEY=ollama export OPENAI_API_BASE=http://localhost:11434/v1 +# `list` reads the model from this env var and rejects --embedding-model. Without +# it, `list` initializes with the default model, mismatches the nomic-built store, +# and throws EmbeddingModelChangedError — silently breaking the count check below. +export DOCS_MCP_EMBEDDING_MODEL="$embedding_model" export DOCS_MCP_SCRAPER_SECURITY_FILE_ACCESS_MODE=unrestricted export DOCS_MCP_SCRAPER_SECURITY_FILE_ACCESS_INCLUDE_HIDDEN=true export DOCS_MCP_SCRAPER_SECURITY_FILE_ACCESS_FOLLOW_SYMLINKS=true -embedding_model="openai:nomic-embed-text" -# Check if library already indexed with the same source URL -existing_url=$(npx -y @arabold/docs-mcp-server@latest list 2>/dev/null \ - | jq -r --arg name "$repo_name" '.[] | select(.name == $name) | .versions[0].sourceUrl // empty') +# Echo the indexed document count for this library+source (0 if absent or empty). +# Spawns npx, so callers keep it off the per-prompt hot path. docs-mcp-server +# lowercases library names on storage, so match the name case-insensitively. +library_doc_count() { + npx -y @arabold/docs-mcp-server@latest list --output json 2>/dev/null \ + | jq -r --arg name "$repo_name" --arg url "$memories_url" \ + 'map(select((.name | ascii_downcase) == ($name | ascii_downcase)))[0].versions[]? + | select(.sourceUrl == $url) | .documentCount // 0' 2>/dev/null \ + | head -1 +} + +# --- Decide whether to (re)index --- +# The cheap local checks (timestamp vs. file mtimes) run every invocation. The +# store-existence probe spawns npx, so it runs only at SessionStart — enough to +# re-add a library whose store was cleared while its timestamp lingered, without +# paying npx on every prompt. Anything changed, or no timestamp yet, => index. +should_index=1 +if [ -f "$TIMESTAMP_FILE" ]; then + newest=$(find "$MEMORIES_DIR" -name '*.md' -newer "$TIMESTAMP_FILE" -print -quit 2>/dev/null) + if [ -z "$newest" ] && [ ! "$MEMORIES_DIR" -nt "$TIMESTAMP_FILE" ]; then + should_index=0 + if [ "$hook_event" = "SessionStart" ]; then + count=$(library_doc_count) + [ "${count:-0}" -gt 0 ] 2>/dev/null || should_index=1 + fi + fi +fi +[ "$should_index" -eq 1 ] || exit 0 + +# --- Index --- +# `scrape` (re)indexes in place whether or not the library exists — adds when +# missing, updates when present — so no separate refresh command is needed. +output=$(npx -y @arabold/docs-mcp-server@latest scrape "$repo_name" "$memories_url" \ + --embedding-model "$embedding_model" 2>&1) +rc=$? -if [ "$existing_url" = "file://$MEMORIES_DIR" ]; then - npx -y @arabold/docs-mcp-server@latest refresh "$repo_name" \ - --embedding-model "$embedding_model" \ - --silent >/dev/null 2>&1 +# Confirm content actually landed using the same trusted count the gate uses: a +# failure — or a #/? truncated path that "succeeds" with 0 pages — leaves the +# count at 0, so we never record a false success that hides the library at zero. +count=$(library_doc_count) +if [ "$rc" -eq 0 ] && [ "${count:-0}" -gt 0 ] 2>/dev/null; then + touch "$TIMESTAMP_FILE" + log "index ok: '$repo_name' ($count docs) <- $memories_url" else - npx -y @arabold/docs-mcp-server@latest scrape "$repo_name" \ - "file://$MEMORIES_DIR" \ - --embedding-model "$embedding_model" \ - --silent >/dev/null 2>&1 + log "index FAILED (rc=$rc, docs=${count:-0}) '$repo_name' <- $memories_url :: $(printf '%s' "$output" | tr '\n' ' ' | tail -c 300)" fi -# Mark indexing time for subsequent staleness checks -touch "$TIMESTAMP_FILE" +exit 0 diff --git a/techpack.yaml b/techpack.yaml index 719a4d6..1c62462 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -160,6 +160,7 @@ components: description: Ignores memory files from version control gitignore: - ".claude/.memories-last-indexed" + - ".claude/.memories-index.log" # --------------------------------------------------------------------------- # Ignore — maintainer-only files not shipped with the pack