feat(plugin): knowledge auto-inject (Path A) — title-first per-turn awareness
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 55s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
This commit is contained in:
2026-06-22 20:31:07 -04:00
parent 807f478cac
commit 8126db3203
6 changed files with 427 additions and 0 deletions
+88
View File
@@ -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<Record<string, string>>("/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 {
</div>
</section>
<section class="settings-section full-width">
<h2>Knowledge auto-inject</h2>
<p class="section-desc">
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 <code>get_note(id)</code> only when it helps. Titles only, each
note at most once per session, and nothing is shown unless it clears the
confidence bar below.
</p>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="kbInjectEnabled" />
Surface relevant note titles each prompt
</label>
<p class="field-hint">Off = notes reach context only when Claude searches for them.</p>
</div>
<div class="field">
<label for="kb-inject-threshold">Confidence threshold (01)</label>
<input
id="kb-inject-threshold"
v-model="kbInjectThreshold"
type="number"
min="0"
max="1"
step="0.05"
class="input"
style="max-width: 8rem"
/>
<p class="field-hint">Minimum similarity to surface a note. Higher = stricter (fewer, more certain). Deliberately above the 0.45 used for searches you trigger yourself.</p>
</div>
<div class="field">
<label for="kb-inject-topk">Max notes per prompt</label>
<input
id="kb-inject-topk"
v-model="kbInjectTopK"
type="number"
min="1"
max="10"
step="1"
class="input"
style="max-width: 8rem"
/>
<p class="field-hint">Ceiling on titles surfaced at once (110).</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveKbInject" :disabled="savingKbInject">
{{ savingKbInject ? 'Saving' : 'Save' }}
</button>
<span v-if="kbInjectSaved" class="saved-msg">Saved!</span>
</div>
</section>
</div>
<!-- ── Account ── -->
+10
View File
@@ -13,6 +13,16 @@
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_autoinject.sh\""
}
]
}
]
}
}
+86
View File
@@ -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
+42
View File
@@ -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():
+115
View File
@@ -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:
+86
View File
@@ -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 = [