feat(plugin): write-path trigger — offer prior art before code is rewritten
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 1m2s

The milestone headline. Auto-inject fires on the operator's prompt; the
moment reuse is actually lost is later, when the agent decides mid-task to
write a helper. A PreToolUse hook on Write|Edit now fires there.

Channel: `additionalContext` with NO permissionDecision, so the note reaches
Claude beside the tool result and the write is never blocked — a recall aid
must not be able to stop the operator's work. Plain stdout would have been
invisible to the model, and deny/ask would have made a nudge into a gate.

Two arms, different in kind:
- BY PLACE — a snippet recorded at this path (or its directory) is prior art
  by definition, not resemblance, so it is neither scored nor thresholded.
  This is what #2083's reverse lookup was built to answer.
- BY MEANING — semantic search restricted to snippets (new `note_type` filter
  on semantic_search_notes) over the code about to be written.
Place ranks first; the top-k cap spans both arms.

Gates carried over from milestone 93 verbatim: threshold, margin, session
dedup, titles-never-bodies. Own `source='write_path'` in retrieval_logs so
precision is tunable separately — the docstring records that the place arm
is unlogged and hands that to #2085.

Its own on/off in Settings but the SAME threshold/top-k: one "how loud may
Scribe be" knob is easier to reason about than two that drift, and splitting
them later is then a data-backed change rather than a guess.

Details worth keeping: the hook sends a REPO-RELATIVE path because that is
how locations are recorded; the git remote resolves to a project and is never
used as the location `repo` filter (different namespaces, would silently
match nothing); the endpoint stays a GET because a read-scoped API key cannot
POST and every other hook depends on that.

plugin.json 0.1.17 -> 0.1.18. Refs #2082, milestone #232.
This commit is contained in:
2026-07-28 09:08:17 -04:00
parent 083944f0fd
commit e0328f2b1c
10 changed files with 810 additions and 3 deletions
+47
View File
@@ -99,6 +99,53 @@ async def autoinject_retrieve():
return jsonify(result)
@plugin_bp.get("/prior-art")
@login_required
async def write_path_prior_art():
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
Answers "what is already recorded for the file about to be written?" — by
place (a snippet at this path or in its directory) and by meaning (snippets
resembling the code about to be written). Titles only, gated, and empty most
of the time. See services.plugin_context.build_write_path_hint.
Query:
path (str) — the target file, REPO-RELATIVE, matching the
convention snippet locations are recorded in. An
absolute path simply won't match anything.
code (optional) — the code about to be written; the semantic query.
Omit it and only the location arm runs.
repo (optional) — working repo remote; resolved to the bound project
to scope the search, exactly as /retrieve does. It
is NOT used as the location `repo` filter — see the
service docstring for why those two differ.
project_id (opt) — explicit project scope override (ad-hoc/testing).
exclude_ids (opt) — comma-separated ids already surfaced this session.
"""
path = (request.args.get("path") or "").strip()
code = request.args.get("code") or ""
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_write_path_hint(
g.user.id, path, code=code, project_id=project_id, exclude_ids=exclude_ids
)
return jsonify(result)
@plugin_bp.get("/processes")
@login_required
async def process_manifest():
+9
View File
@@ -114,6 +114,7 @@ async def semantic_search_notes(
threshold: float = _SIMILARITY_THRESHOLD,
project_id: int | None = None,
is_task: bool | None = None,
note_type: str | None = None,
orphan_only: bool = False,
scope: str = "own",
) -> list[tuple[float, Note]]:
@@ -122,6 +123,9 @@ async def semantic_search_notes(
Scores are cosine similarities in [-1, 1]; only notes at or above
*threshold* are returned, sorted highest-first.
`note_type` narrows to a single record kind (e.g. "snippet"), for callers
that want prior art rather than everything embedded.
`scope` ("own" | "browse" | "read", see access.notes_visibility_clause)
decides how far this may see. It exists because this one function serves
three different kinds of act: an explicit search, which should reach
@@ -175,6 +179,11 @@ async def semantic_search_notes(
stmt = stmt.where(Note.status.isnot(None))
elif is_task is False:
stmt = stmt.where(Note.status.is_(None))
# Narrow to one kind of record. Composes with is_task rather than
# replacing it — 'snippet' is a non-task note_type, so a caller asking
# for prior art gets snippets and not the dev-log that mentions them.
if note_type:
stmt = stmt.where(Note.note_type == note_type)
if exclude_ids:
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
stmt = stmt.where(distance <= max_distance).order_by(distance.asc()).limit(limit)
+183
View File
@@ -14,6 +14,7 @@ index alone already steers behavior.
"""
from __future__ import annotations
import logging
import re
import time
@@ -25,11 +26,14 @@ 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 import snippets as snippets_svc
from scribe.services.access import label_shared_items, owner_names_for
from scribe.services.embeddings import semantic_search_notes
from scribe.services.retrieval_telemetry import record_retrieval
from scribe.services.settings import get_setting
logger = logging.getLogger(__name__)
# Defensive cap below Claude Code's 10k additionalContext limit.
_MAX_CHARS = 9000
@@ -51,6 +55,17 @@ AUTOINJECT_DEFAULT_ENABLED = True
AUTOINJECT_DEFAULT_THRESHOLD = 0.55
AUTOINJECT_DEFAULT_TOP_K = 3
# The write-path trigger (#2082) gets its own on/off switch but SHARES the
# threshold and top-k above. One knob for "how loud may Scribe be" is easier to
# reason about than two that drift; the switch is separate because wanting prior
# art on writes while keeping prompts quiet (or the reverse) is a real
# preference, and it's the only part of the gate an operator can judge without
# data. The two surfaces log under different `source` values, so if the
# telemetry ever shows they want different thresholds, splitting them is a
# data-backed change rather than a guess made up front.
WRITEPATH_ENABLED_KEY = "kb_writepath_enabled"
WRITEPATH_DEFAULT_ENABLED = True
# 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
@@ -259,6 +274,174 @@ async def build_autoinject_hint(
return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg}
# --- Write-path trigger (#2082): prior art at the moment code is written ------
# Auto-inject above fires on the operator's prompt. The moment reuse is actually
# lost is later — when the AGENT decides mid-task to write a helper — and nothing
# fired there. This is that trigger: the plugin's PreToolUse hook on Write/Edit
# asks what prior art is already recorded for the file being written.
#
# Two arms, deliberately different in kind:
# - BY PLACE — a snippet recorded at this path (or in its directory) is prior
# art by definition, not by resemblance, so it isn't scored or thresholded.
# This is what the reverse lookup (#2083) was built to answer.
# - BY MEANING — semantic search over snippets only, using the code about to be
# written, under the same gates as auto-inject.
# Place beats meaning in the menu because "there is already a canonical helper in
# this exact file" is a stronger claim than "this resembles something".
def _prior_art_line(item: dict, marker: str, owner: str | None) -> str:
"""One menu line: `- #12 [here] "title"`, attributed when it isn't yours."""
title = (item.get("title") or "(untitled)").replace("\n", " ").strip()
line = f"> - #{item['id']} [{marker}] \"{title}\""
if owner:
line += f" — shared by {owner}, treat as a suggestion"
return line
async def get_writepath_config(user_id: int) -> dict:
"""Write-path trigger settings: its own `enabled`, auto-inject's gates."""
cfg = await get_autoinject_config(user_id)
enabled_raw = await get_setting(
user_id, WRITEPATH_ENABLED_KEY,
"true" if WRITEPATH_DEFAULT_ENABLED else "false",
)
return {
**cfg,
"enabled": enabled_raw.strip().lower() in ("true", "1", "yes", "on"),
}
async def build_write_path_hint(
user_id: int,
path: str,
code: str = "",
project_id: int = 0,
exclude_ids: list[int] | None = None,
) -> dict:
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
`path` is the file about to be written, REPO-RELATIVE — matching the
convention snippet locations are recorded in. `code` is what's about to be
written, used only as the semantic query.
Same four anti-bloat gates as auto-inject (threshold, margin, session dedup
via `exclude_ids`, titles-never-bodies) plus the shared top-k cap across BOTH
arms — so a file with a lot of recorded history can't turn one edit into a
wall of text. Returns empty context when disabled, when there's no path, or
when nothing is recorded — which is the common case, and the point.
Note the repo↔project mapping is deliberately one-way: the hook sends a git
remote, which the ROUTE resolves to `project_id` through the repo bindings.
It is never used as the location `repo` filter — a snippet's `repo` is a
free-text label the operator typed ("Scribe"), not a remote URL, and matching
one against the other would silently return nothing.
Returns {"context": str, "note_ids": list[int], "config": dict}. The semantic
arm is logged to retrieval_logs as source='write_path' — its own source, so
its precision is tunable separately from auto-inject's.
KNOWN GAP: only the semantic arm is logged, matching auto-inject's convention
of recording the candidate set for threshold tuning. Location hits carry no
score, so folding them in would corrupt the score distribution the log exists
to capture — but it does mean a snippet surfaced BY PLACE leaves no trace. The
usage signal (#2085) correlates retrieval_logs against later pulls, so it will
need a home for un-scored surfacing before it can measure this arm.
"""
cfg = await get_writepath_config(user_id)
empty = {"context": "", "note_ids": [], "config": cfg}
path = (path or "").strip()
if not cfg["enabled"] or not path:
return empty
top_k = cfg["top_k"]
excluded = set(exclude_ids or [])
scope_project = project_id or None
# --- arm 1: by place ---
here: list[dict] = []
nearby: list[dict] = []
try:
here, _ = await snippets_svc.list_snippets(
user_id, path=path, limit=top_k, project_id=scope_project,
)
directory = path.rsplit("/", 1)[0] if "/" in path else ""
if directory and len(here) < top_k:
nearby, _ = await snippets_svc.list_snippets(
user_id, path=directory, limit=top_k, project_id=scope_project,
)
except Exception:
logger.warning("Write-path location lookup failed", exc_info=True)
seen: set[int] = set(excluded)
placed: list[tuple[str, dict]] = []
for marker, items in (("here", here), ("nearby", nearby)):
for item in items:
nid = int(item["id"])
if nid in seen:
continue
seen.add(nid)
placed.append((marker, item))
# --- arm 2: by meaning ---
scored: list[tuple[str, dict]] = []
remaining = top_k - len(placed)
query = (code or "").strip()
if remaining > 0 and query:
t0 = time.perf_counter()
hits = await semantic_search_notes(
user_id, query,
limit=remaining,
threshold=cfg["threshold"],
project_id=scope_project,
exclude_ids=seen,
note_type="snippet",
# Same reasoning as auto-inject: nobody asked for this, so it takes
# the browse scope and never surfaces a one-to-one direct share.
scope="browse",
)
record_retrieval(
user_id=user_id, source="write_path", query=query,
threshold=cfg["threshold"], limit=remaining,
project_id=scope_project, is_task=False, results=hits,
duration_ms=(time.perf_counter() - t0) * 1000.0,
)
if hits:
top_score = hits[0][0]
for score, note in hits:
if score < top_score - _AUTOINJECT_BAND:
continue
scored.append((
f"similar {score:.2f}",
{"id": int(note.id), "title": note.title, "user_id": note.user_id},
))
menu = (placed + scored)[:top_k]
if not menu:
return empty
owners = await owner_names_for({
int(it["user_id"]) for _m, it in menu
if it.get("user_id") is not None and int(it["user_id"]) != user_id
})
lines = [
f"> Prior art already recorded in Scribe for `{path}` — open one with "
"`get_snippet(id)` and reuse it rather than writing a fresh one-off "
"(titles only; shown once per session):",
]
note_ids: list[int] = []
for marker, item in menu:
note_ids.append(int(item["id"]))
owner_id = item.get("user_id")
owner = None
if owner_id is not None and int(owner_id) != user_id:
owner = owners.get(int(owner_id)) or "another user"
lines.append(_prior_art_line(item, marker, owner))
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: