CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.
Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.
The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
7.5 KiB
Bash
Executable File
162 lines
7.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Grimoire plugin — UserPromptSubmit push channel (knowledge auto-inject, Path A).
|
|
#
|
|
# On each user prompt, asks the operator's Grimoire instance for a TITLE-FIRST
|
|
# awareness hint: the few notes that clear the per-user auto-inject gates
|
|
# (high-confidence threshold, margin gate, session dedup, top-k). Titles + ids
|
|
# only — never bodies; the agent calls get_note(id) to pull anything it judges
|
|
# relevant. Most turns inject nothing.
|
|
#
|
|
# TWO ARMS, on one request. Rules and preferences are retrieved
|
|
# against the same prompt and returned in the same payload, ahead of the notes
|
|
# menu. That arm exists because the two act arms are keyed on a file write or
|
|
# a command, so a rule governing what to SAY — extract intent from loose
|
|
# phrasing, raise a conflict before acting, end a finding with an offer —
|
|
# would have no moment to fire at. The operator's message is the only query that exists
|
|
# before a response is composed.
|
|
#
|
|
# The two arms are gated separately server-side: turning the notes menu off
|
|
# leaves rules arriving, because they are different claims with different
|
|
# costs of being missed.
|
|
#
|
|
# Best-effort enrichment ONLY: unlike the SessionStart channel there is no
|
|
# static floor here. If the instance is unconfigured/unreachable, or anything
|
|
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
|
#
|
|
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code
|
|
# with the userConfig key UPPERCASED (reading the lowercase spelling silently
|
|
# disables this hook, and silence is indistinguishable from "nothing
|
|
# cleared the threshold"):
|
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
|
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
|
#
|
|
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
|
# note is injected at most once per session. Passed back as exclude_ids.
|
|
set -uo pipefail
|
|
|
|
# shellcheck source=plugin/hooks/scribe_defs.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
|
|
|
command -v curl >/dev/null 2>&1 || exit 0
|
|
|
|
# UserPromptSubmit delivers a JSON event on stdin: { prompt, session_id, cwd, ... }
|
|
# Parsed ONCE into flat lines and then queried three times: a prompt is
|
|
# the largest payload any hook reads, and re-parsing it per field is three
|
|
# passes over the same text.
|
|
event=$(cat 2>/dev/null || true)
|
|
event_flat=$(printf '%s' "$event" | scribe_json_flat)
|
|
prompt=$(scribe_json_pick "$event_flat" '.prompt')
|
|
session_id=$(scribe_json_pick "$event_flat" '.session_id')
|
|
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
|
|
transcript=$(scribe_json_pick "$event_flat" '.transcript_path')
|
|
|
|
# Nothing to retrieve against.
|
|
[ -n "$prompt" ] || exit 0
|
|
|
|
# A turn CLAUDE CODE wrote, not the operator — a task notification, a
|
|
# slash-command echo, a local command's caveat banner. Leaving before the
|
|
# request matters more for the LOG than for the noise: a retrieval call
|
|
# recorded against a notification inflates this surface's denominator and
|
|
# seeds `near_misses` with demand nobody expressed. scribe_skip_prompt carries
|
|
# the reasoning.
|
|
scribe_skip_prompt "$prompt" && exit 0
|
|
|
|
# Unconfigured install → silent (auto-inject is pure enrichment).
|
|
scribe_config || exit 0
|
|
|
|
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
|
# `head -c`, not `cut -c1-2000`: cut is line-oriented and caps EACH LINE, so a
|
|
# long multi-line prompt would sail past the budget. scripts/check_plugin.py
|
|
# checks every hook for this.
|
|
q=$(printf '%s' "$prompt" | head -c 2000)
|
|
# Encoded whole, never line by line: per-line encoding joins the pieces with
|
|
# raw newlines, an invalid URL, and the long multi-line prompts most worth
|
|
# retrieving against would be the ones dropped. scribe_urlenc reads bytes and
|
|
# has no notion of a line.
|
|
q_enc=$(printf '%s' "$q" | scribe_urlenc) || exit 0
|
|
[ -n "$q_enc" ] || exit 0
|
|
|
|
# What the conversation is about, for a prompt too short to say. Sent
|
|
# beside `q`, never folded into it: the server decides whether the prompt is
|
|
# thin enough to need it, for the notes and rule arms alike. Capped at
|
|
# 600 chars here as well as there, so the URL stays small whatever the reply.
|
|
ctx_q=""
|
|
ctx=$(scribe_recent_context "$transcript")
|
|
if [ -n "$ctx" ]; then
|
|
ctx_enc=$(printf '%s' "$ctx" | scribe_urlenc) && [ -n "$ctx_enc" ] && ctx_q="&ctx=${ctx_enc}"
|
|
fi
|
|
|
|
# Scope to this directory's project — a `.scribe` marker, else the git remote.
|
|
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
|
scope=$(scribe_scope_query "$repo_dir")
|
|
repo_q=""
|
|
[ -n "$scope" ] && repo_q="&${scope}"
|
|
|
|
# Per-session dedup: ids already injected this session are skipped.
|
|
state_dir="${TMPDIR:-/tmp}/scribe-autoinject"
|
|
mkdir -p "$state_dir" 2>/dev/null || true
|
|
# RULES DEDUP IN A DIFFERENT DIRECTORY, and it has to be this one. The rule
|
|
# ledger is SHARED by every arm that can name a rule — the two PreToolUse
|
|
# hooks already keep it under scribe-priorart — so that one session keeps ONE
|
|
# list and a rule named here is not re-announced before the next Bash call.
|
|
# A private copy here would make each arm's "already seen" mean something
|
|
# different. The directory is named for the prior-art hook, which is not a
|
|
# scope claim.
|
|
rule_state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
|
mkdir -p "$rule_state_dir" 2>/dev/null || true
|
|
idfile=""
|
|
rulefile=""
|
|
shapefile=""
|
|
exclude_q=""
|
|
if [ -n "$session_id" ]; then
|
|
# session_id is an opaque token from Claude Code; keep only filename-safe chars.
|
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
|
idfile="$state_dir/${safe_sid}.ids"
|
|
rulefile="$rule_state_dir/${safe_sid}.rules.ids"
|
|
if [ -f "$idfile" ]; then
|
|
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
|
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
|
fi
|
|
# AGED, not read flat: an exclusion that never expires means a rule surfaced
|
|
# once in a long session is silenced for the rest of it, even as the session
|
|
# stops holding what it was told. scribe_rules_live carries the reasoning.
|
|
rule_seen=$(scribe_rules_live "$rulefile")
|
|
[ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}"
|
|
# What the session actually OPENED, as against what it was shown.
|
|
exclude_q="${exclude_q}$(scribe_held_query "$rule_state_dir/${safe_sid}.opened.ids")"
|
|
# Which reply shapes it already holds in full: the core is
|
|
# sent whole once, and as its one-line reminder on every later turn.
|
|
shapefile=$(scribe_shapes_file "$safe_sid")
|
|
shapes_seen=$(scribe_shapes_seen "$shapefile")
|
|
[ -n "$shapes_seen" ] && exclude_q="${exclude_q}&shapes_seen=${shapes_seen}"
|
|
fi
|
|
|
|
body=$(curl -fsS --max-time 5 \
|
|
-H "Authorization: Bearer ${token}" \
|
|
"${url%/}/api/plugin/retrieve?q=${q_enc}${ctx_q}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
|
|
[ -n "$body" ] || exit 0
|
|
|
|
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
|
context=$(scribe_json_pick "$body_flat" '.context')
|
|
[ -n "$context" ] || exit 0
|
|
|
|
# Remember the surfaced ids so they aren't injected again this session.
|
|
if [ -n "$idfile" ]; then
|
|
scribe_json_list "$body_flat" '.note_ids' >> "$idfile" || true
|
|
fi
|
|
# Rules onto the SHARED ledger, stamped so they can age out. Only FRESH ids
|
|
# come back in rule_ids — a rule rendered as a repeat is already on
|
|
# the ledger, and re-appending it would keep pushing its stamp forward so it
|
|
# never aged at all.
|
|
if [ -n "$rulefile" ]; then
|
|
scribe_json_list "$body_flat" '.rule_ids' \
|
|
| scribe_rules_append "$rulefile"
|
|
fi
|
|
if [ -n "$shapefile" ]; then
|
|
scribe_json_list "$body_flat" '.shape_keys' | scribe_shapes_append "$shapefile"
|
|
fi
|
|
|
|
scribe_json_out UserPromptSubmit "$context"
|
|
exit 0
|