feat(plugin): auto-inject retrieves on the conversation, not the typed words alone (#4364)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:05:21 -04:00
co-authored by Claude Opus 5.5
parent a01deeb851
commit 574b27ae74
6 changed files with 188 additions and 4 deletions
+40
View File
@@ -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")