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
+39 -8
View File
@@ -32,6 +32,11 @@ from scribe.services import notes as notes_svc
SNIPPET_NOTE_TYPE = "snippet"
SNIPPET_TAG = "snippet"
# Sentinel for "argument not supplied" on update, so None stays available as a
# real value meaning "clear this". Needed for project_id, where 0 is not a valid
# id and None is the clear — the two can't share one default.
UNSET: object = object()
def _embed_snippet(note) -> None:
"""Fire-and-forget embedding refresh for a snippet.
@@ -235,11 +240,13 @@ def parse_snippet_fields(
fields["language"] = m.group(1).strip()
fields["code"] = m.group(2)
if not fields["language"]:
for t in tags or []:
if t and t != SNIPPET_TAG:
fields["language"] = t
break
# Language fallback for a body whose code fence lost its language. Only the
# FIRST tag can be trusted: compose_tags emits [language, "snippet", *caller],
# so a leading tag that isn't the marker is the language — while a leading
# marker means no language was recorded. Scanning for "first tag that isn't
# the marker" instead would promote a caller's plain tag to the language.
if not fields["language"] and tags and tags[0] != SNIPPET_TAG:
fields["language"] = tags[0]
return fields
@@ -302,8 +309,13 @@ async def list_snippets(
tag: str = "",
limit: int = 50,
offset: int = 0,
project_id: int | None = None,
) -> tuple[list[dict], int]:
"""List snippets (id/title/tags/preview dicts), most-recently-updated first."""
"""List snippets (id/title/tags/preview dicts), most-recently-updated first.
``project_id`` narrows to one project; omit it to reach across every project
— which is the point when the thing you're about to write was already solved
somewhere else."""
return await knowledge_svc.query_knowledge(
user_id=user_id,
note_type=SNIPPET_NOTE_TYPE,
@@ -312,6 +324,7 @@ async def list_snippets(
q=q,
limit=max(1, min(limit, 100)),
offset=max(0, offset),
project_id=project_id,
)
@@ -329,12 +342,15 @@ async def update_snippet(
symbol: str | None = None,
locations: list[dict] | None = None,
tags: list[str] | None = None,
project_id: int | None = None,
project_id: int | None | object = UNSET,
):
"""Partial update: only fields passed (not None) change. Re-serializes the
merged field set back into title/body/tags. Returns the Note, or None if the
id isn't a snippet.
``project_id``: omit to leave unchanged, pass None to detach from its
project, pass an id to move it.
Locations: ``locations`` replaces the whole set; else a legacy single
``repo``/``path``/``symbol`` overlays onto the first existing location; else
the existing locations are kept."""
@@ -377,7 +393,7 @@ async def update_snippet(
fields["tags"] = compose_tags(
merged["language"], tags if tags is not None else existing_extra
)
if project_id is not None:
if project_id is not UNSET:
fields["project_id"] = project_id
updated = await notes_svc.update_note(user_id, snippet_id, **fields)
@@ -387,6 +403,21 @@ async def update_snippet(
return updated
async def delete_snippet(user_id: int, snippet_id: int) -> bool:
"""Retire a snippet to the trash (recoverable). Returns False if the id isn't
the user's snippet.
Recall makes this corrective, not merely tidy: a wrong or obsolete snippet
doesn't sit quietly — it keeps being offered as prior art. Removing it has to
be reachable from wherever it was recorded.
"""
note = await get_snippet(user_id, snippet_id)
if note is None:
return False
from scribe.services.trash import delete as trash_delete
return await trash_delete(note.user_id, "note", snippet_id) is not None
# --- merge: unify found one-offs into one canonical snippet ------------------
def _extra_tags(tags: list[str] | None, language: str = "") -> list[str]: