From 8126db32034aed261b001e0865a3f85f3741dfab Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 22 Jun 2026 20:31:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20knowledge=20auto-inject=20(Path?= =?UTF-8?q?=20A)=20=E2=80=94=20title-first=20per-turn=20awareness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New UserPromptSubmit hook (scribe_autoinject.sh) + GET /api/plugin/retrieve that surface the TITLES (never bodies) of the few notes clearing four anti-bloat gates: a per-user confidence threshold (stricter than pull search), a margin gate, per-session dedup (exclude_ids), and a top-k ceiling. Each retrieval is logged to retrieval_logs as source=auto_inject so the threshold can be tuned from data. Per-user config (enable / threshold / top-k) is DB-backed via /api/settings with a Settings UI card; defaults enabled, threshold 0.55, top-k 3 (conservative — tune once auto_inject telemetry accrues). Scribe: project 2, milestone 93, task 1033. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E --- frontend/src/views/SettingsView.vue | 88 ++++++++++++++++++++ plugin/hooks/hooks.json | 10 +++ plugin/hooks/scribe_autoinject.sh | 86 +++++++++++++++++++ src/scribe/routes/plugin.py | 42 ++++++++++ src/scribe/services/plugin_context.py | 115 ++++++++++++++++++++++++++ tests/test_services_plugin_context.py | 86 +++++++++++++++++++ 6 files changed, 427 insertions(+) create mode 100755 plugin/hooks/scribe_autoinject.sh diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index d3f7791..f694c75 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -17,6 +17,13 @@ const timezoneSaved = ref(false); const trashRetentionDays = ref("90"); const savingRetention = ref(false); const retentionSaved = ref(false); +// Knowledge auto-inject (per-user). Defaults mirror the backend +// (services/plugin_context: enabled, threshold 0.55, top-k 3). +const kbInjectEnabled = ref(true); +const kbInjectThreshold = ref("0.55"); +const kbInjectTopK = ref("3"); +const savingKbInject = ref(false); +const kbInjectSaved = ref(false); // think_enabled setting removed 2026-05-23. The chat+curator architecture // has tools=[] on the chat model; think on a no-tools conversational pass @@ -56,6 +63,28 @@ async function saveRetention() { savingRetention.value = false; } } + +async function saveKbInject() { + const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0)); + const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1))); + kbInjectThreshold.value = String(t); + kbInjectTopK.value = String(k); + savingKbInject.value = true; + kbInjectSaved.value = false; + try { + await apiPut('/api/settings', { + kb_autoinject_enabled: kbInjectEnabled.value ? 'true' : 'false', + kb_autoinject_threshold: String(t), + kb_autoinject_top_k: String(k), + }); + kbInjectSaved.value = true; + setTimeout(() => (kbInjectSaved.value = false), 2000); + } catch { + toastStore.show('Failed to save auto-inject settings', 'error'); + } finally { + savingKbInject.value = false; + } +} const newEmail = ref(""); const emailPassword = ref(""); const changingEmail = ref(false); @@ -435,6 +464,13 @@ onMounted(async () => { const allSettings = await apiGet>("/api/settings"); userTimezone.value = allSettings.user_timezone ?? ""; trashRetentionDays.value = allSettings.trash_retention_days ?? "90"; + kbInjectEnabled.value = allSettings.kb_autoinject_enabled !== "false"; + if (allSettings.kb_autoinject_threshold !== undefined) { + kbInjectThreshold.value = allSettings.kb_autoinject_threshold; + } + if (allSettings.kb_autoinject_top_k !== undefined) { + kbInjectTopK.value = allSettings.kb_autoinject_top_k; + } if (allSettings.notify_task_reminders !== undefined) { notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; } @@ -1165,6 +1201,58 @@ function formatUserDate(iso: string): string { +
+

Knowledge auto-inject

+

+ When enabled, the Scribe plugin quietly surfaces the titles of your most + relevant notes on each prompt — never their full text — so Claude can pull + one in with get_note(id) only when it helps. Titles only, each + note at most once per session, and nothing is shown unless it clears the + confidence bar below. +

+
+ +

Off = notes reach context only when Claude searches for them.

+
+
+ + +

Minimum similarity to surface a note. Higher = stricter (fewer, more certain). Deliberately above the 0.45 used for searches you trigger yourself.

+
+
+ + +

Ceiling on titles surfaced at once (1–10).

+
+
+ + Saved! +
+
+ diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 23c40f9..3b1b802 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -13,6 +13,16 @@ } ] } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_autoinject.sh\"" + } + ] + } ] } } diff --git a/plugin/hooks/scribe_autoinject.sh b/plugin/hooks/scribe_autoinject.sh new file mode 100755 index 0000000..d10a51b --- /dev/null +++ b/plugin/hooks/scribe_autoinject.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Scribe plugin — UserPromptSubmit push channel (knowledge auto-inject, Path A). +# +# On each user prompt, asks the operator's Scribe 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. +# +# 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: +# 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 + +command -v jq >/dev/null 2>&1 || exit 0 +command -v curl >/dev/null 2>&1 || exit 0 + +# UserPromptSubmit delivers a JSON event on stdin: { prompt, session_id, cwd, ... } +event=$(cat 2>/dev/null || true) +prompt=$(printf '%s' "$event" | jq -r '.prompt // empty' 2>/dev/null) || prompt="" +session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id="" +event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd="" + +# Nothing to retrieve against. +[ -n "$prompt" ] || exit 0 + +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 +# Unconfigured install → silent (auto-inject is pure enrichment). +[ -n "$url" ] && [ -n "$token" ] || exit 0 + +# 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 + +# 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="" + [ -n "$enc" ] && repo_q="&repo=${enc}" +fi + +# Per-session dedup: ids already injected this session are skipped. +state_dir="${TMPDIR:-/tmp}/scribe-autoinject" +mkdir -p "$state_dir" 2>/dev/null || true +idfile="" +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" + if [ -f "$idfile" ]; then + seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//') + [ -n "$seen" ] && exclude_q="&exclude_ids=${seen}" + fi +fi + +body=$(curl -fsS --max-time 5 \ + -H "Authorization: Bearer ${token}" \ + "${url%/}/api/plugin/retrieve?q=${q_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0 +[ -n "$body" ] || exit 0 + +context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0 +[ -n "$context" ] || exit 0 + +# Remember the surfaced ids so they aren't injected again this session. +if [ -n "$idfile" ]; then + printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true +fi + +jq -n --arg c "$context" \ + '{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}' +exit 0 diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index 9dbf08a..1dc4498 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -57,6 +57,48 @@ async def session_context(): return jsonify(result) +@plugin_bp.get("/retrieve") +@login_required +async def autoinject_retrieve(): + """Title-first knowledge auto-inject for the plugin's UserPromptSubmit hook. + + Given the user's prompt (`q`), returns a compact awareness hint — note + titles + scores only, never bodies — for the top hits that clear the + per-user gates (see services.plugin_context.build_autoinject_hint). Returns + empty context (most of the time) when disabled or nothing is relevant. + + Query: + q (str) — the user's prompt to retrieve against. + repo (optional) — working repo remote; resolved to the bound project + to scope the search (mirrors /context). Unbound or + absent → searches all the user's notes. + project_id (opt) — explicit project scope override (ad-hoc/testing). + exclude_ids (opt) — comma-separated note ids already injected this + session; skipped so each note injects at most once. + """ + q = (request.args.get("q") or "").strip() + try: + project_id = int(request.args.get("project_id", 0) or 0) + except (TypeError, ValueError): + project_id = 0 + + repo = (request.args.get("repo") or "").strip() + if repo and not project_id: + resolved = await repo_bindings_svc.resolve_project(g.user.id, repo) + if resolved: + project_id = resolved + + exclude_ids = [ + int(p) for p in (request.args.get("exclude_ids") or "").split(",") + if p.strip().isdigit() + ] + + result = await plugin_ctx_svc.build_autoinject_hint( + g.user.id, q, project_id=project_id, exclude_ids=exclude_ids + ) + return jsonify(result) + + @plugin_bp.get("/processes") @login_required async def process_manifest(): diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index e31d6bb..bf740c7 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -15,6 +15,7 @@ index alone already steers behavior. from __future__ import annotations import re +import time from sqlalchemy import select @@ -24,6 +25,9 @@ from scribe.services import knowledge as knowledge_svc from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_svc +from scribe.services.embeddings import semantic_search_notes +from scribe.services.retrieval_telemetry import record_retrieval +from scribe.services.settings import get_setting # Defensive cap below Claude Code's 10k additionalContext limit. _MAX_CHARS = 9000 @@ -31,6 +35,28 @@ _MAX_CHARS = 9000 # Max chars of a Process body to fold into the auto-surface description. _PROC_PREVIEW_CHARS = 200 +# --- Knowledge auto-inject (Path A: per-turn awareness push) ----------------- +# Per-user settings (keys live in the generic settings table). The threshold is +# deliberately STRICTER than the pull-search default (embeddings +# DEFAULT_SIMILARITY_THRESHOLD = 0.45): an unsolicited per-turn inject must clear +# a higher bar than a search the agent chose to run. Defaults start conservative +# and are meant to be tuned from retrieval_logs (source='auto_inject') once data +# accrues — they're exposed in the Settings UI, no restart needed. +AUTOINJECT_ENABLED_KEY = "kb_autoinject_enabled" +AUTOINJECT_THRESHOLD_KEY = "kb_autoinject_threshold" +AUTOINJECT_TOP_K_KEY = "kb_autoinject_top_k" + +AUTOINJECT_DEFAULT_ENABLED = True +AUTOINJECT_DEFAULT_THRESHOLD = 0.55 +AUTOINJECT_DEFAULT_TOP_K = 3 + +# Margin gate: drop any hit more than this far below the top hit's score, so a +# single strong match doesn't drag in a wall of barely-passing neighbours. +_AUTOINJECT_BAND = 0.10 +# Hard ceiling on top-k regardless of the user's setting — this is an +# awareness menu (titles only), never a content dump. +_AUTOINJECT_MAX_TOP_K = 10 + def _slugify(text: str) -> str: """kebab-case slug for a skill directory name (a-z0-9 + single hyphens).""" @@ -82,6 +108,95 @@ async def build_process_manifest(user_id: int) -> dict: return {"processes": procs, "total": len(procs)} +async def get_autoinject_config(user_id: int) -> dict: + """Resolve a user's auto-inject settings, falling back to the defaults. + + Returns {"enabled": bool, "threshold": float, "top_k": int}, clamped to + sane ranges (threshold to [0,1]; top_k to [1, _AUTOINJECT_MAX_TOP_K]). + """ + enabled_raw = await get_setting( + user_id, AUTOINJECT_ENABLED_KEY, + "true" if AUTOINJECT_DEFAULT_ENABLED else "false", + ) + enabled = enabled_raw.strip().lower() in ("true", "1", "yes", "on") + + try: + threshold = float(await get_setting( + user_id, AUTOINJECT_THRESHOLD_KEY, str(AUTOINJECT_DEFAULT_THRESHOLD))) + except (TypeError, ValueError): + threshold = AUTOINJECT_DEFAULT_THRESHOLD + threshold = min(1.0, max(0.0, threshold)) + + try: + top_k = int(float(await get_setting( + user_id, AUTOINJECT_TOP_K_KEY, str(AUTOINJECT_DEFAULT_TOP_K)))) + except (TypeError, ValueError): + top_k = AUTOINJECT_DEFAULT_TOP_K + top_k = min(_AUTOINJECT_MAX_TOP_K, max(1, top_k)) + + return {"enabled": enabled, "threshold": threshold, "top_k": top_k} + + +async def build_autoinject_hint( + user_id: int, + query: str, + project_id: int = 0, + exclude_ids: list[int] | None = None, +) -> dict: + """Title-first awareness hint for the plugin's UserPromptSubmit hook. + + The four anti-bloat gates (see the module + milestone-93 design): + 1. high-confidence threshold (stricter than pull) — set per-user; + 2. margin gate — keep only hits within _AUTOINJECT_BAND of the top score; + 3. session dedup — caller passes already-injected ids as `exclude_ids`; + 4. title-first payload — id + title + score only, never bodies. + Disabled, blank-query, or nothing-clears-the-gates all return empty context, + so most turns inject nothing. + + Returns {"context": str, "note_ids": list[int], "config": dict}. Every + retrieval (even empty) is logged to retrieval_logs as source='auto_inject' + so the threshold can be tuned from data. + """ + cfg = await get_autoinject_config(user_id) + empty = {"context": "", "note_ids": [], "config": cfg} + q = (query or "").strip() + if not cfg["enabled"] or not q: + return empty + + t0 = time.perf_counter() + hits = await semantic_search_notes( + user_id, q, + limit=cfg["top_k"], + threshold=cfg["threshold"], + project_id=(project_id or None), + exclude_ids=set(exclude_ids or []), + ) + record_retrieval( + user_id=user_id, source="auto_inject", query=q, + threshold=cfg["threshold"], limit=cfg["top_k"], + project_id=(project_id or None), is_task=None, results=hits, + duration_ms=(time.perf_counter() - t0) * 1000.0, + ) + if not hits: + return empty + + # Margin gate: keep only hits close to the strongest one. + top_score = hits[0][0] + kept = [(s, n) for s, n in hits if s >= top_score - _AUTOINJECT_BAND] + + lines = [ + "> Possibly relevant from your Scribe notes — call `get_note(id)` to " + "open any in full (titles only; injected once per session):", + ] + note_ids: list[int] = [] + for score, note in kept: + note_ids.append(int(note.id)) + title = (note.title or "(untitled)").replace("\n", " ").strip() + lines.append(f"> - #{note.id} \"{title}\" ({score:.2f})") + + return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg} + + async def _topic_titles(topic_ids: set[int]) -> dict[int, str]: """Map topic_id -> title for the given ids (live topics only).""" if not topic_ids: diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py index 63d96bd..0c30b4f 100644 --- a/tests/test_services_plugin_context.py +++ b/tests/test_services_plugin_context.py @@ -10,6 +10,92 @@ def _rule(rid, title, topic_id): return r +def _note(nid, title): + n = MagicMock() + n.id, n.title = nid, title + return n + + +# ─── knowledge auto-inject (Path A) ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_autoinject_config_defaults_and_clamps(): + from scribe.services import plugin_context as pc + + # No settings stored → defaults. + with patch.object(pc, "get_setting", AsyncMock(side_effect=lambda uid, k, d: d)): + cfg = await pc.get_autoinject_config(1) + assert cfg == { + "enabled": pc.AUTOINJECT_DEFAULT_ENABLED, + "threshold": pc.AUTOINJECT_DEFAULT_THRESHOLD, + "top_k": pc.AUTOINJECT_DEFAULT_TOP_K, + } + + # Out-of-range values are clamped; top_k capped at the hard ceiling. + stored = { + pc.AUTOINJECT_ENABLED_KEY: "false", + pc.AUTOINJECT_THRESHOLD_KEY: "5", + pc.AUTOINJECT_TOP_K_KEY: "999", + } + with patch.object(pc, "get_setting", + AsyncMock(side_effect=lambda uid, k, d: stored.get(k, d))): + cfg = await pc.get_autoinject_config(1) + assert cfg["enabled"] is False + assert cfg["threshold"] == 1.0 + assert cfg["top_k"] == pc._AUTOINJECT_MAX_TOP_K + + +@pytest.mark.asyncio +async def test_build_autoinject_hint_disabled_returns_empty_and_skips_search(): + from scribe.services import plugin_context as pc + search = AsyncMock() + with patch.object(pc, "get_autoinject_config", + AsyncMock(return_value={"enabled": False, "threshold": 0.55, "top_k": 3})), \ + patch.object(pc, "semantic_search_notes", search), \ + patch.object(pc, "record_retrieval", MagicMock()): + out = await pc.build_autoinject_hint(1, "anything") + assert out["context"] == "" and out["note_ids"] == [] + search.assert_not_called() # disabled → no retrieval at all + + +@pytest.mark.asyncio +async def test_build_autoinject_hint_titles_only_with_margin_gate(): + from scribe.services import plugin_context as pc + # top=0.80; 0.74 within band (0.10), 0.61 outside → dropped. + hits = [(0.80, _note(11, "Pool sizing decision")), + (0.74, _note(22, "run_maintenance thresholds")), + (0.61, _note(33, "unrelated-ish"))] + rec = MagicMock() + with patch.object(pc, "get_autoinject_config", + AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3})), \ + patch.object(pc, "semantic_search_notes", AsyncMock(return_value=hits)), \ + patch.object(pc, "record_retrieval", rec): + out = await pc.build_autoinject_hint(1, "postgres pool", project_id=2, + exclude_ids=[99]) + # Margin gate kept the top two, dropped the straggler. + assert out["note_ids"] == [11, 22] + assert '#11 "Pool sizing decision" (0.80)' in out["context"] + assert "#33" not in out["context"] + # Title-first: no body text, ever. + assert "get_note(id)" in out["context"] + # Telemetry fired with the auto_inject source and the full candidate set. + rec.assert_called_once() + assert rec.call_args.kwargs["source"] == "auto_inject" + + +@pytest.mark.asyncio +async def test_build_autoinject_hint_blank_query_returns_empty(): + from scribe.services import plugin_context as pc + search = AsyncMock() + with patch.object(pc, "get_autoinject_config", + AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3})), \ + patch.object(pc, "semantic_search_notes", search): + out = await pc.build_autoinject_hint(1, " ") + assert out["context"] == "" + search.assert_not_called() + + @pytest.mark.asyncio async def test_build_session_context_renders_titles_grouped_by_topic(): rules = [