From 574b27ae7448412cd420ce730af11f9a878465f9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 23 Sep 2026 16:05:21 -0400 Subject: [PATCH] feat(plugin): auto-inject retrieves on the conversation, not the typed words alone (#4364) The prompt hook sent only the operator's message, so a mid-session follow-up ("yes do that") named nothing a note, lesson or rule could match. The hook now reads the tail of the last assistant reply from the transcript and sends it as `ctx`; the notes and rule arms append it to a short prompt (<= 280 chars), prompt first, capped at 600 chars. No model tokens: it is embedding input, and the injected menu's budget is unchanged. Co-Authored-By: Claude Opus 5.5 --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_autoinject.sh | 13 +++- plugin/hooks/scribe_defs.sh | 27 ++++++++ src/scribe/routes/plugin.py | 12 +++- src/scribe/services/plugin_context.py | 40 +++++++++++ tests/test_autoinject_context.py | 98 +++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 tests/test_autoinject_context.py diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index f90277a..391590b 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", - "version": "2026.09.21.0503", + "version": "2026.09.23.2005", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/hooks/scribe_autoinject.sh b/plugin/hooks/scribe_autoinject.sh index 48193a9..c560aa3 100755 --- a/plugin/hooks/scribe_autoinject.sh +++ b/plugin/hooks/scribe_autoinject.sh @@ -49,6 +49,7 @@ 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 @@ -79,6 +80,16 @@ q=$(printf '%s' "$prompt" | head -c 2000) 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 (#4364). 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") @@ -120,7 +131,7 @@ 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 + "${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) diff --git a/plugin/hooks/scribe_defs.sh b/plugin/hooks/scribe_defs.sh index 5bb3a36..661d58a 100644 --- a/plugin/hooks/scribe_defs.sh +++ b/plugin/hooks/scribe_defs.sh @@ -127,6 +127,33 @@ scribe_json_flat_lines() { awk -v mode=lines -f "$SCRIBE_HOOK_DIR/scribe_json.awk" 2>/dev/null || true } +# $1 transcript path → the tail of the last assistant REPLY text, decoded, on +# one line; "" when there is none (#4364). The auto-inject query pairs it with +# a short prompt, so "yes do that" still says what it is about. +# +# Cheap by construction, because it runs on every prompt: the last 512 KB only, +# and grep narrows to assistant records carrying a text block BEFORE anything is +# parsed — a transcript is mostly tool results, and parsing those in awk is the +# multi-second cost scribe_report_check.sh already measured. Fixed strings with +# UNESCAPED quotes can only match at a record's own top level (see that hook). +# A sidechain is a subagent talking, not this session, so it is skipped. +scribe_recent_context() { + [ -n "${1:-}" ] && [ -f "$1" ] || return 0 + tail -c 524288 "$1" 2>/dev/null \ + | grep -F '"role":"assistant"' 2>/dev/null \ + | grep -F '"type":"text"' 2>/dev/null \ + | tail -n 4 \ + | scribe_json_flat_lines \ + | awk -F'\t' ' + $2 == ".isSidechain" { side[$1] = $3; next } + $2 ~ /^\.message\.content\[[0-9]+\]\.text$/ { txt[$1] = txt[$1] " " $3; if ($1 > last) last = $1 } + END { for (i = last; i >= 0; i--) if ((i in txt) && side[i] != "true") { print txt[i]; exit } } + ' 2>/dev/null \ + | scribe_json_unescape \ + | tr '\n\t' ' ' \ + | awk '{ n = length($0); print (n > 600 ? substr($0, n - 599) : $0) }' +} + # $1 flat text, $2 exact path → the decoded scalar, or "" if absent. # # `null` READS AS ABSENT, matching the `// empty` every call site used to carry. diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index 0d63961..5768d32 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -95,6 +95,12 @@ async def autoinject_retrieve(): 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. + ctx (opt) — the tail of the last assistant reply (#4364). The + notes AND rule arms append it to a short prompt, + so a follow-up like "yes do that" still names + what it is about — and a rule or lesson arrives + while the work is under way, before the operator + has to call it out. exclude_rule_ids — comma-separated rule ids already surfaced this (opt) session. SHARED with /prior-art and /tool-rules on purpose: one session keeps ONE rule ledger, so a @@ -125,11 +131,13 @@ async def autoinject_retrieve(): exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids")) held_rule_ids = _int_list(request.args.get("held_rule_ids")) + ctx = request.args.get("ctx") or "" rules = await plugin_ctx_svc.build_prompt_rule_hint( - g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids + g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids, + context=ctx, ) result = await plugin_ctx_svc.build_autoinject_hint( - g.user.id, q, project_id=project_id, exclude_ids=exclude_ids + g.user.id, q, project_id=project_id, exclude_ids=exclude_ids, context=ctx, ) blocks = [b for b in (rules["context"], result["context"]) if b] result["context"] = "\n\n".join(blocks) diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 7b6665d..8b6886b 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -537,6 +537,36 @@ _AUTOINJECT_BAND = 0.10 # was an accident of which arm got a configurable budget first. _AUTOINJECT_MAX_TOP_K = MAX_BUDGET +# THE CONVERSATION BESIDE THE PROMPT (#4364). The operator's message is the +# only query this arm had, and mid-session it is mostly a follow-up — "yes do +# that", "now fix the filter" — that names nothing a record could match. The +# hook now sends the tail of the last assistant reply as `context`, and it is +# appended to a SHORT prompt only: a prompt that already says what it is about +# is the better query on its own, and diluting it is the one way this can make +# retrieval worse. +# +# Neither number costs a model token. The query is embedding input; what the +# session pays for is the menu, and the budget bounds that unchanged. +# 280 — about two sentences. Above it a prompt carries its own subject. +# 600 — the context cap. Prompt + context stays well inside bge-small's +# 512-token window, prompt FIRST, so truncation can only ever cut +# context and never the operator's words. +_AUTOINJECT_CONTEXT_PROMPT_MAX = 280 +_AUTOINJECT_CONTEXT_MAX = 600 + + +def _autoinject_query(prompt: str, context: str) -> str: + """The prompt, with recent conversation appended when the prompt is thin. + + The prompt leads and context is its tail, cut from the END of the reply — + a reply's closing lines are where it says what it did and what is next, + which is what the operator's follow-up is answering. + """ + ctx = " ".join((context or "").split())[-_AUTOINJECT_CONTEXT_MAX:] + if not ctx or len(prompt) > _AUTOINJECT_CONTEXT_PROMPT_MAX: + return prompt + return f"{prompt}\n\n{ctx}" + # --- the prompt-boundary rule arm (#3852) ------------------------------------ # # Both existing rule arms are keyed on something the session is about to DO — @@ -961,6 +991,7 @@ async def build_autoinject_hint( query: str, project_id: int = 0, exclude_ids: list[int] | None = None, + context: str = "", ) -> dict: """Title-first awareness hint for the plugin's UserPromptSubmit hook. @@ -982,6 +1013,9 @@ async def build_autoinject_hint( q = (query or "").strip() if not cfg["enabled"] or not q: return empty + # Everything below searches, logs and fills its slots on the ENRICHED + # query, so the telemetry row records what was actually asked (#4364). + q = _autoinject_query(q, context) # THE LEDGER LEAVES THE SEARCH (#4101). `exclude_ids` used to go into # `semantic_search_notes` itself, so a record this session had already been @@ -1286,6 +1320,7 @@ async def build_prompt_rule_hint( project_id: int = 0, exclude_rule_ids: list[int] | None = None, held_rule_ids: list[int] | None = None, + context: str = "", ) -> dict: """Rules and preferences that may apply to what the operator just asked. @@ -1319,6 +1354,11 @@ async def build_prompt_rule_hint( q = (query or "").strip() if not q: return out + # The same enrichment the notes arm gets (#4364). A rule is meant to + # arrive while the work it governs is under way, not once the operator + # names it — and "yes, go ahead" names nothing a trigger can match, while + # the reply it answers ("commit this to dev and push") does. + q = _autoinject_query(q, context) try: threshold = await floor_for(user_id, "prompt_rule") diff --git a/tests/test_autoinject_context.py b/tests/test_autoinject_context.py new file mode 100644 index 0000000..ba21b30 --- /dev/null +++ b/tests/test_autoinject_context.py @@ -0,0 +1,98 @@ +"""The auto-inject query carries the conversation beside a thin prompt (#4364). + +The notes arm used to retrieve against the operator's typed words alone, and a +mid-session follow-up — "yes do that", "now fix the filter" — names nothing a +record can match. The hook now reads the tail of the last assistant reply out +of the transcript and sends it as `ctx`; the server appends it to a SHORT +prompt only. Two halves, pinned separately: what the server builds, and what +the hook extracts. +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from scribe.services.plugin_context import ( + _AUTOINJECT_CONTEXT_MAX, + _AUTOINJECT_CONTEXT_PROMPT_MAX, + _autoinject_query, +) +from tests.helpers import need_tools + +DEFS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_defs.sh" + + +# --- the query the server builds --------------------------------------------- + +def test_a_short_prompt_is_followed_by_the_context(): + q = _autoinject_query("yes do that", "I'll move the library filters into the side column.") + assert q.startswith("yes do that\n\n") + assert "library filters" in q + + +def test_a_prompt_that_says_what_it_is_about_is_left_alone(): + long_prompt = "x" * (_AUTOINJECT_CONTEXT_PROMPT_MAX + 1) + assert _autoinject_query(long_prompt, "anything at all") == long_prompt + + +def test_no_context_is_the_prompt_unchanged(): + assert _autoinject_query("yes do that", "") == "yes do that" + assert _autoinject_query("yes do that", " \n ") == "yes do that" + + +def test_context_is_cut_from_the_end_of_the_reply(): + # The reply's close is where it says what it did and what is next — the + # part the operator's follow-up is answering. + ctx = "HEAD " + "m" * 2000 + " TAIL" + q = _autoinject_query("ok", ctx) + tail = q.split("\n\n", 1)[1] + assert len(tail) == _AUTOINJECT_CONTEXT_MAX + assert tail.endswith("TAIL") and "HEAD" not in tail + + +# --- what the hook extracts -------------------------------------------------- + +def _rec(role: str, blocks: list[dict], sidechain: bool = False) -> str: + return json.dumps({ + "type": role, "isSidechain": sidechain, + "message": {"role": role, "content": blocks}, + }, separators=(",", ":")) + + +def recent(tmp_path: Path, lines: list[str]) -> str: + need_tools("bash", "awk", "grep", "tail") + t = tmp_path / "t.jsonl" + t.write_text("\n".join(lines) + "\n") + r = subprocess.run( + ["bash", "-c", f'set -uo pipefail\n. "{DEFS}"\nscribe_recent_context "{t}"'], + capture_output=True, + ) + assert r.returncode == 0, r.stderr.decode() + return r.stdout.decode().strip() + + +def test_the_hook_reads_the_last_assistant_text(tmp_path): + out = recent(tmp_path, [ + _rec("assistant", [{"type": "text", "text": "an older reply"}]), + _rec("assistant", [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]), + _rec("assistant", [{"type": "text", "text": "Shall I file it as an issue?\nNext: the hook."}]), + ]) + assert out == "Shall I file it as an issue? Next: the hook." + + +def test_a_subagent_reply_is_not_this_session(tmp_path): + out = recent(tmp_path, [ + _rec("assistant", [{"type": "text", "text": "the session's reply"}]), + _rec("assistant", [{"type": "text", "text": "a subagent's report"}], sidechain=True), + ]) + assert out == "the session's reply" + + +def test_the_hook_caps_what_it_sends(tmp_path): + out = recent(tmp_path, [_rec("assistant", [{"type": "text", "text": "a" * 5000 + "END"}])]) + assert len(out) == 600 and out.endswith("END") + + +def test_no_transcript_is_silence_not_failure(tmp_path): + assert recent(tmp_path, []) == ""