From c569cdd0eb0703428b9d514e7ccbcc6486052b7c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Jul 2026 18:00:11 -0400 Subject: [PATCH 1/6] fix(plugin): every hook's credential + URL encoding was broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, each of which independently made a hook a no-op, and all three failing silently — which is why the whole dynamic side of the plugin looked "shipped" while doing nothing. 1. Wrong env var case. Claude Code exports userConfig to hooks as CLAUDE_PLUGIN_OPTION_ with the key UPPERCASED. All four hooks read CLAUDE_PLUGIN_OPTION_api_endpoint / _api_token, so both values were always empty. That killed the SessionStart dynamic tier, process sync, prompt auto-inject, and the write-path prior-art trigger at once. 2. jq -rR is line-oriented. `@uri` under -R encodes input LINE BY LINE, so a multi-line payload came back as several encoded lines joined by raw newlines — an invalid URL, curl fails, hook exits 0 in silence. Now -sRr. This one hid behind (1): auto-inject only ever worked for single-line prompts, and prior-art (which posts code, always multi-line) could never have worked at all. 3. cut -c1-1200 caps each LINE, not the payload, so the prior-art code budget wasn't a budget. Now head -c 1200. Also widens the SessionStart warning: "neither URL nor token arrived" used to be treated as a benign unconfigured install and stayed quiet. That is exactly the state defect (1) produced, so the one install state that most needed a signal was the only one that emitted none. It now says so, and names the two other features it silently disables. Verified against the live instance: dynamic rules + project context load, auto-inject surfaces #2192 on a multi-line prompt, and the write-path trigger returns the [here] place-arm hit on a multi-line edit with session dedup suppressing the repeat. Refs #2198, #2082 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- plugin/hooks/scribe_autoinject.sh | 21 +++++++---- plugin/hooks/scribe_prior_art.sh | 28 +++++++++----- plugin/hooks/scribe_session_context.sh | 51 +++++++++++++++----------- plugin/hooks/scribe_sync_processes.sh | 8 ++-- 4 files changed, 68 insertions(+), 40 deletions(-) diff --git a/plugin/hooks/scribe_autoinject.sh b/plugin/hooks/scribe_autoinject.sh index d10a51b..a182936 100755 --- a/plugin/hooks/scribe_autoinject.sh +++ b/plugin/hooks/scribe_autoinject.sh @@ -11,9 +11,12 @@ # 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: -# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash -# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive) +# Config (same as scribe_session_context.sh), exported to the hook by Claude Code +# with the userConfig key UPPERCASED (see #2198 — 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 @@ -32,8 +35,8 @@ event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_c # Nothing to retrieve against. [ -n "$prompt" ] || exit 0 -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded ${...} placeholder arriving as a literal. case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac @@ -42,14 +45,18 @@ case "$token" in *'${'*) token="" ;; esac # Cap the query length — a giant prompt makes a giant URL for no extra signal. q=$(printf '%s' "$prompt" | cut -c1-2000) -q_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || exit 0 +# `-sRr`, not `-rR`: jq -R reads LINE BY LINE, so a multi-line prompt encoded as +# several lines joined by raw newlines and the request died. Single-line prompts +# worked, which is why this looked healthy — the long, substantial prompts most +# worth retrieving against were exactly the ones silently dropped. -s slurps. +q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0 # Resolve the working repo's remote so the server can scope to the bound project. repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}} repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) repo_q="" if [ -n "$repo" ]; then - enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc="" + enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc="" [ -n "$enc" ] && repo_q="&repo=${enc}" fi diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index 8ae9bd1..1b79998 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -13,9 +13,11 @@ # Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A # recall aid must not be able to stop the operator's work. # -# Config (same as the other hooks), exported to the hook by Claude Code: -# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash -# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive) +# Config (same as the other hooks), exported to the hook by Claude Code with the +# userConfig key UPPERCASED (see #2198 — the lowercase spelling reads as empty +# and this hook then exits 0 in silence, looking exactly like "no prior art"): +# 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. set -uo pipefail @@ -46,8 +48,8 @@ case "$file_path" in exit 0 ;; esac -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded ${...} placeholder arriving as a literal. case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac @@ -70,16 +72,24 @@ fi # token limit well before this, so a bigger slice buys no extra signal — and the # payload has to stay a GET (a read-scoped API key cannot POST, and every other # plugin hook works with a read key). -q=$(printf '%s' "$code" | cut -c1-1200) +# `head -c`, not `cut -c1-1200`: cut is line-oriented and caps each line +# separately, so a 400-line edit sailed past the "1200 char" budget entirely and +# built a URL from the whole payload. head -c caps the total, which is the point. +q=$(printf '%s' "$code" | head -c 1200) -path_enc=$(printf '%s' "$rel_path" | jq -rR '@uri' 2>/dev/null) || exit 0 -code_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || code_enc="" +# `-sRr`, not `-rR`: jq -R reads input LINE BY LINE, so a multi-line payload came +# back as several separately-encoded lines joined by raw newlines — an invalid +# URL that made curl fail, and this hook then exited 0 in silence. -s slurps the +# whole input into one string first. Newlines are exactly what code contains, so +# this hook could never have worked without it (issue #2198 / #2082). +path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || exit 0 +code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc="" # Resolve the working repo's remote so the server can scope to the bound project. repo=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true) repo_q="" if [ -n "$repo" ]; then - enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc="" + enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc="" [ -n "$enc" ] && repo_q="&repo=${enc}" fi diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index fa40586..9cfec0c 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -4,16 +4,18 @@ # Tier 1 (STATIC, always fires, no auth, no network): injects a bundled # behavioral mandate (scribe_static_context.md) so a fresh session knows to # reach for Scribe — record work, recall before acting — even when the instance -# is unreachable OR the API token never reached this hook. The latter is a known -# Claude Code gap: sensitive userConfig values aren't always exported to the -# hook subprocess, so the dynamic tier can silently get nothing. The static tier -# is the load-bearing floor that does not depend on the key or the network. +# is unreachable or unconfigured. The static tier is the load-bearing floor that +# does not depend on the key or the network. # # Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance # for always-on rules + active-project context and appends it. Config comes from # the plugin's userConfig, exported to hooks as: -# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash -# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive) +# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash +# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive) +# NOTE THE CASE: Claude Code uppercases the userConfig key when exporting it, so +# the `api_token` option arrives as CLAUDE_PLUGIN_OPTION_API_TOKEN. Reading the +# lowercase spelling silently yields nothing — that was issue #2198, and it +# disabled the dynamic tier, auto-inject, and the write-path trigger at once. # The active project is resolved server-side from the working repo's git remote # (see services/repo_bindings); bind each repo once with the bind_repo MCP tool. # @@ -25,16 +27,16 @@ # can't make the model flush, and can't know the in-flight task ids; the durable # path is record-as-you-go + this post-compaction reload.) # -# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in -# hooks.json — sensitive values are kept in the keychain and never spliced into -# a hook command line, so the placeholder arrives unexpanded. The harness env -# vars above are the supported channel; SCRIBE_URL / SCRIBE_TOKEN override for -# the settings.json dogfooding path. +# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in a +# shell-form hooks.json command — Claude Code rejects that outright (splicing a +# configured value into a shell command line would let the shell run whatever it +# contains). The env vars above are the supported channel; SCRIBE_URL / +# SCRIBE_TOKEN override for the settings.json dogfooding path. # -# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session. A *failed* -# dynamic fetch is surfaced as a short status line (not swallowed). A fully -# unconfigured install (no url AND no token) is the intended static-only mode -# and stays quiet. +# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session, and every +# way it can come up empty produces a short status line — failed fetch, missing +# token, and missing-everything alike. Nothing about the credential path is +# allowed to fail quietly; see the #2198 comment at the status block below. set -uo pipefail command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely @@ -55,8 +57,8 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1" [ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md") # --- Tier 2: dynamic rules + active-project context (best-effort) --- -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded `${...}` placeholder reaching us as a literal — it # would otherwise be sent as a garbage Bearer token and 401. Treat as unset. @@ -71,7 +73,7 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) q="" if [ -n "$repo" ]; then - enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc="" + enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc="" [ -n "$enc" ] && q="?repo=${enc}" fi body=$(curl -fsS --max-time 8 \ @@ -80,9 +82,16 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then [ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) [ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed." elif [ -n "$url" ] && [ -z "$token" ]; then - # Endpoint configured but token absent: the signature of the known Claude Code - # userConfig export gap (sensitive values not always reaching the hook). - status="> ⚠️ Scribe: live context disabled this session — the API token did not reach this hook (a known Claude Code plugin-config gap). Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`." + status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`." +elif [ -z "$url" ] && [ -z "$token" ]; then + # NEITHER value arrived. Previously this case stayed silent as "an unconfigured + # install", which made issue #2198 invisible for weeks: a *casing* bug here + # (reading CLAUDE_PLUGIN_OPTION_api_token when Claude Code exports the key + # UPPERCASED) looks identical to never having configured the plugin, and + # silently disabled auto-inject and the write-path trigger too. It is not a + # benign state — the plugin prompts for both values at enable time, so if + # neither reached the hook, something is wrong. Say so. + status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`." fi [ -n "$dyn" ] && append "$dyn" diff --git a/plugin/hooks/scribe_sync_processes.sh b/plugin/hooks/scribe_sync_processes.sh index d9bd7e3..6422e45 100755 --- a/plugin/hooks/scribe_sync_processes.sh +++ b/plugin/hooks/scribe_sync_processes.sh @@ -18,14 +18,16 @@ # FAIL-OPEN & SILENT: never blocks a session; emits NOTHING on stdout (so it's # safe as a second SessionStart hook). On any fetch failure it exits without # touching existing stubs — a transient outage must not wipe the user's skills. -# Config mirrors the context hook (CLAUDE_PLUGIN_OPTION_* / SCRIBE_* override). +# Config mirrors the context hook: CLAUDE_PLUGIN_OPTION_API_ENDPOINT / +# CLAUDE_PLUGIN_OPTION_API_TOKEN (userConfig key UPPERCASED by Claude Code — see +# #2198), with SCRIBE_URL / SCRIBE_TOKEN as the override. set -uo pipefail command -v jq >/dev/null 2>&1 || exit 0 command -v curl >/dev/null 2>&1 || exit 0 -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded `${...}` placeholder arriving as a literal. case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac From 2b85443dd19f467666321529d83d41a5bd0112cb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Jul 2026 18:09:17 -0400 Subject: [PATCH 2/6] =?UTF-8?q?feat(snippets):=20usage=20signal=20?= =?UTF-8?q?=E2=80=94=20was=20a=20surfaced=20record=20ever=20actually=20pul?= =?UTF-8?q?led=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retrieval_logs answers "what did the ranker return, at what scores" — the right substrate for tuning a threshold. It cannot answer the question the snippet corpus actually needs: did anyone open this? A snippet nobody opens is not neutral. It takes a slot in every future auto-inject menu and crowds out something useful. Adds note_usage_events (migration 0071): one row per note per event, either 'surfaced' (we put its title in front of an agent) or 'pulled' (someone opened it in full), tagged with which surface produced it. Closes the gap #2082 recorded against this work. The write-path PLACE arm carries no score, so it has no home in retrieval_logs — folding it in would corrupt the score distribution that table exists to capture. The result was that the arm firing on the STRONGEST claim ("there is already a canonical helper in this exact file") was the one arm nobody could measure. Both arms now emit usage events under distinct sources, so their pull-through rates are finally comparable. Deliberate departures from the task as written: - Not in-session correlation. The original framing was "correlate result_ids against a later get_note in the same session." There is no session identity server-side — the MCP endpoint is stateless and the hooks send no session id — and adding one would mean threading an opaque client-supplied token through every read path. Two independent counters answer the question without it: surfaced 40×, pulled 0 is dead weight regardless of how those events distribute across sessions. - Pulls record at the ENTRY POINTS (MCP tools, REST detail route), not in snippets_svc.get_snippet, which update and merge also reach. Counting those would inflate precisely the number meant to say "someone chose to look at this." - get_note records for every note kind, not just snippets. The auto-inject menu surfaces tasks and processes too; scoping this to snippets would pin those at zero pulls forever and make them read as dead weight next to snippets that merely had a counter. Surfaced in the Snippets list as an "N/M used" badge, warning-toned once a record has been offered 3+ times and never opened, with the tooltip saying what to do about it (usually: its "when to reach for it" doesn't say when). No badge at all below one surfacing — "0/0" reads as a verdict when it's an absence of evidence. Also returned from MCP list_snippets so the agent can see dead weight without opening the UI. Telemetry keeps the retrieval_telemetry contract throughout: writes are fire-and-forget, reads degrade to zeroes, and no path can raise into the surface it observes. Refs #2085 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- alembic/versions/0071_note_usage_events.py | 72 ++++++++ frontend/src/api/snippets.ts | 12 ++ frontend/src/views/SnippetListView.vue | 59 +++++++ src/scribe/mcp/tools/notes.py | 6 + src/scribe/mcp/tools/snippets.py | 27 ++- src/scribe/models/__init__.py | 1 + src/scribe/models/note_usage.py | 76 +++++++++ src/scribe/routes/snippets.py | 14 ++ src/scribe/services/note_usage.py | 155 +++++++++++++++++ src/scribe/services/plugin_context.py | 31 +++- tests/test_note_usage.py | 185 +++++++++++++++++++++ 11 files changed, 626 insertions(+), 12 deletions(-) create mode 100644 alembic/versions/0071_note_usage_events.py create mode 100644 src/scribe/models/note_usage.py create mode 100644 src/scribe/services/note_usage.py create mode 100644 tests/test_note_usage.py diff --git a/alembic/versions/0071_note_usage_events.py b/alembic/versions/0071_note_usage_events.py new file mode 100644 index 0000000..c9ac3cb --- /dev/null +++ b/alembic/versions/0071_note_usage_events.py @@ -0,0 +1,72 @@ +"""add note_usage_events — did anyone actually open what we surfaced? + +Revision ID: 0071 +Revises: 0070 +Create Date: 2026-07-28 + +`retrieval_logs` records what the ranker returned and with what scores, which is +the right substrate for tuning a similarity threshold. It cannot answer the +different question the snippet corpus needs: was a surfaced snippet ever pulled +in full? A snippet nobody opens still competes for the injection budget on every +turn, so the surfaced:pulled ratio is what makes dead weight visible. + +Two reasons this is its own table rather than columns on `notes` or rows in +`retrieval_logs`: + + - Counters on `notes` would answer "how many" but not "when, from where, and + by which arm" — and the place arm vs semantic arm comparison is precisely + what was missing (the write-path place arm surfaced snippets while leaving + no trace anywhere). + - Folding un-scored surfacing into `retrieval_logs` would corrupt the score + distribution that table exists to capture. Location hits have no score. + +Grain is one row per note per event, which is what the per-snippet readout needs +and what `retrieval_logs.result_ids` (a JSONB array, one row per *call*) cannot +be indexed at. + +FK-free on note_id and user_id, matching retrieval_logs and app_logs: telemetry +should outlive what it describes. Deleting a note must not erase the evidence +that it was surfaced forty times and opened none. + +Downgrade drops the table outright. The data is purely observational — nothing +reads it for correctness, so losing it costs history and no behavior. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0071" +down_revision = "0070" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "note_usage_events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("note_id", sa.Integer(), nullable=False), + sa.Column("event", sa.Text(), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + ) + # Every readout is "these note ids, split by event", so the composite is the + # one that actually gets used; the others serve pruning and per-user views. + op.create_index( + "ix_note_usage_note_event", "note_usage_events", ["note_id", "event"] + ) + op.create_index("ix_note_usage_created_at", "note_usage_events", ["created_at"]) + op.create_index("ix_note_usage_user_id", "note_usage_events", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_note_usage_user_id", table_name="note_usage_events") + op.drop_index("ix_note_usage_created_at", table_name="note_usage_events") + op.drop_index("ix_note_usage_note_event", table_name="note_usage_events") + op.drop_table("note_usage_events") diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index fa7ef03..e912d4c 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -46,6 +46,16 @@ export interface Snippet { owner?: string | null; } +/** How often a record was put in front of an agent versus actually opened. + * A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a + * slot in every future auto-inject menu while never being used. */ +export interface SnippetUsage { + surfaced_count: number; + pull_count: number; + last_surfaced_at: string | null; + last_pulled_at: string | null; +} + /** Lightweight list item from the knowledge preview feed. Note: the `snippet` * field here is a truncated *body preview* (the knowledge feed's naming), not * the parsed fields above. */ @@ -61,6 +71,8 @@ export interface SnippetListItem { * them, not one of your own. Absent means it's yours. */ shared?: boolean; owner?: string | null; + /** Always present from the backend, zero-filled for records with no events. */ + usage?: SnippetUsage; } /** Create/update payload — discrete fields the backend serializes into the diff --git a/frontend/src/views/SnippetListView.vue b/frontend/src/views/SnippetListView.vue index 8fbed78..2af502f 100644 --- a/frontend/src/views/SnippetListView.vue +++ b/frontend/src/views/SnippetListView.vue @@ -126,6 +126,40 @@ function splitTitle(title: string): { name: string; when: string } { function languageOf(tags: string[]): string { return tags.find((t) => t && t !== "snippet") ?? ""; } + +/** A snippet that has been offered repeatedly and never opened. The threshold + * is 3 rather than 1 because one or two surfacings is noise — the record may + * simply not have come up in a relevant context yet. */ +function isDeadWeight(s: SnippetListItem): boolean { + const u = s.usage; + return !!u && u.pull_count === 0 && u.surfaced_count >= 3; +} + +/** Short badge text, or "" to render nothing. A record nobody has surfaced yet + * gets no badge at all: "0 / 0" would read as a verdict when it's an absence + * of evidence. */ +function usageBadge(s: SnippetListItem): string { + const u = s.usage; + if (!u || u.surfaced_count === 0) return ""; + return `${u.pull_count}/${u.surfaced_count} used`; +} + +function usageTitle(s: SnippetListItem): string { + const u = s.usage; + if (!u) return ""; + const last = u.last_pulled_at + ? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.` + : "Never opened."; + const verdict = isDeadWeight(s) + ? " Offered repeatedly without ever being opened — consider rewriting its" + + " “when to reach for it” so it says when, or deleting it. It takes a slot" + + " in every future auto-inject menu." + : ""; + return ( + `Surfaced to an agent ${u.surfaced_count}×, opened in full ` + + `${u.pull_count}×. ${last}${verdict}` + ); +}