fix(snippets): close the recall-surface gaps found reviewing the Drafter
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s

Four defects from the 2026-07-25 review of the recall (#227) and merge (#231)
milestones. The theme: a snippet could be recorded but not fully corrected, and
the agent and web surfaces had drifted apart.

- #2076 language was mis-derived from the first caller tag, so a snippet created
  with tags and no language read that tag back as its language — corrupting the
  tag set and the code fence on the next update. Only the FIRST tag can carry
  the language, since compose_tags emits [language, "snippet", *caller].
- #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be
  cleared and no snippet detached from its project. Now an omitted field is left
  alone, an empty string clears, and project_id follows the -1 = detach
  convention. A service-level UNSET sentinel keeps None available as the clear.
- #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet
  could not be retired by the agent that recorded it), locations on MCP create
  and update, system_ids through the REST routes and the editor, and the
  near-duplicate gate on REST create with a "record it anyway" escape.
- #2079 project scoping: list_snippets takes project_id through the service, the
  MCP tool and the REST route, defaulting to every project — reaching across
  projects is the point when the helper you need was written elsewhere.

Sharing the list across owners is deliberately NOT in here: query_knowledge is
shared with the Knowledge browse surface, so widening it changes behaviour well
beyond snippets. Left open on #2079 for a scope decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
2026-07-25 19:00:05 -04:00
parent 7a81b7333e
commit b33e2a79c6
12 changed files with 492 additions and 43 deletions
+67 -21
View File
@@ -16,13 +16,19 @@ from scribe.services import snippets as snippets_svc
from scribe.services import systems as systems_svc
async def list_snippets(q: str = "", tag: str = "", limit: int = 50) -> dict:
async def list_snippets(
q: str = "", tag: str = "", limit: int = 50, project_id: int = 0,
) -> dict:
"""List recorded snippets (reusable functions/components).
Args:
q: Free-text search across name + body (optional).
q: Free-text search across name + body (optional). Matches on meaning as
well as wording, so describe what you need the code to DO.
tag: Filter to a single tag, e.g. a language like "python" (optional).
limit: Max results (1-100).
project_id: Narrow to one project. 0 (default) searches every project —
usually what you want, since a helper you need here may well have
been written somewhere else.
Returns {"snippets": [{id, title, tags, preview}], "total": int}. The title
reads "name — when to reach for it"; open one in full with get_snippet(id).
@@ -30,6 +36,7 @@ async def list_snippets(q: str = "", tag: str = "", limit: int = 50) -> dict:
uid = current_user_id()
items, total = await snippets_svc.list_snippets(
uid, q=q or None, tag=tag, limit=max(1, min(limit, 100)),
project_id=project_id or None,
)
return {"snippets": items, "total": total}
@@ -43,6 +50,7 @@ async def create_snippet(
repo: str = "",
path: str = "",
symbol: str = "",
locations: list[dict] | None = None,
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
@@ -65,6 +73,9 @@ async def create_snippet(
when_to_use: One line on when to reach for it — this becomes part of the
title, so it's what a recall menu shows. Keep it sharp.
repo/path/symbol: Canonical location of the reference implementation.
locations: Several locations at once, as [{"repo","path","symbol"}, ...],
when you already know the thing lives in more than one place. Takes
precedence over the single repo/path/symbol shorthand.
tags: Extra plain-string tags (language + "snippet" are added for you).
project_id: Associate with a project (0 = no project). Snippets surface
proactively within their project; search finds them across projects.
@@ -87,6 +98,7 @@ async def create_snippet(
body = snippets_svc.compose_body(
code=code, language=language, signature=signature,
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
locations=locations,
)
if not force:
dup = await dedup_svc.find_duplicate_note(
@@ -99,7 +111,7 @@ async def create_snippet(
note = await snippets_svc.create_snippet(
uid, name=name, code=code, language=language, signature=signature,
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
tags=tags, project_id=project_id or None,
locations=locations, tags=tags, project_id=project_id or None,
)
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
@@ -123,31 +135,48 @@ async def get_snippet(snippet_id: int) -> dict:
async def update_snippet(
snippet_id: int,
name: str = "",
code: str = "",
language: str = "",
signature: str = "",
when_to_use: str = "",
repo: str = "",
path: str = "",
symbol: str = "",
name: str | None = None,
code: str | None = None,
language: str | None = None,
signature: str | None = None,
when_to_use: str | None = None,
repo: str | None = None,
path: str | None = None,
symbol: str | None = None,
locations: list[dict] | None = None,
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
) -> dict:
"""Update a snippet. Only provided fields change — empty strings leave that
field unchanged; pass tags to replace the extra-tag set."""
uid = current_user_id()
"""Update a snippet. Only the fields you pass change.
def _n(v: str) -> str | None:
return v if v != "" else None
An omitted field is left alone; an EMPTY STRING clears it — so a stale
signature, a wrong "when to use", or an obsolete location can be removed, not
just overwritten. A snippet that surfaces in recall with wrong details is
worse than none, so correcting downward has to be possible.
Args:
locations: Replace the whole location set, as [{"repo","path","symbol"},
...]. Pass [] to clear every location. The single repo/path/symbol
args instead overlay onto the FIRST location, leaving the rest.
tags: Replaces the extra-tag set (language + "snippet" are re-derived).
project_id: 0 leaves it unchanged, -1 detaches it from its project, a
positive id moves it.
"""
uid = current_user_id()
if project_id == 0:
project = snippets_svc.UNSET
elif project_id < 0:
project = None
else:
project = project_id
note = await snippets_svc.update_snippet(
uid, snippet_id,
name=_n(name), code=_n(code), language=_n(language),
signature=_n(signature), when_to_use=_n(when_to_use),
repo=_n(repo), path=_n(path), symbol=_n(symbol),
tags=tags, project_id=project_id or None,
name=name, code=code, language=language,
signature=signature, when_to_use=when_to_use,
repo=repo, path=path, symbol=symbol,
locations=locations, tags=tags, project_id=project,
)
if note is None:
raise ValueError(f"snippet {snippet_id} not found")
@@ -161,6 +190,20 @@ async def update_snippet(
return data
async def delete_snippet(snippet_id: int) -> dict:
"""Retire a snippet you recorded — it moves to the trash and is recoverable.
Reach for this when a snippet is wrong, obsolete, or was never worth keeping.
A recorded snippet is offered as prior art on every matching turn, so a bad
one costs more than a missing one. If instead it's a duplicate of something
that should survive, prefer merge_snippets — that keeps the call sites.
"""
uid = current_user_id()
if not await snippets_svc.delete_snippet(uid, snippet_id):
raise ValueError(f"snippet {snippet_id} not found")
return {"deleted": True, "id": snippet_id}
async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
"""Unify duplicate/variant snippets INTO one canonical record — the cure for
the same reusable thing recorded as several one-offs.
@@ -194,5 +237,8 @@ async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
def register(mcp) -> None:
for fn in (list_snippets, create_snippet, get_snippet, update_snippet, merge_snippets):
for fn in (
list_snippets, create_snippet, get_snippet, update_snippet,
delete_snippet, merge_snippets,
):
mcp.tool(name=fn.__name__)(fn)