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
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:
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user