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
+6 -1
View File
@@ -36,6 +36,7 @@ export interface Snippet {
created_at: string;
updated_at: string;
snippet: SnippetFields;
systems?: { id: number; name: string }[];
}
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
@@ -63,14 +64,18 @@ export interface SnippetInput {
locations?: SnippetLocation[];
tags?: string[];
project_id?: number | null;
system_ids?: number[];
/** Create only: record it even though a near-duplicate already exists. */
force?: boolean;
}
export async function listSnippets(
params: { q?: string; tag?: string } = {},
params: { q?: string; tag?: string; projectId?: number | null } = {},
): Promise<{ snippets: SnippetListItem[]; total: number }> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.tag) qs.set("tag", params.tag);
if (params.projectId) qs.set("project_id", String(params.projectId));
const query = qs.toString();
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
}
+165 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from "vue";
import { ref, computed, onMounted, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getSnippet,
@@ -8,11 +8,15 @@ import {
type SnippetInput,
type SnippetLocation,
} from "@/api/snippets";
import { ApiError } from "@/api/client";
import ProjectSelector from "@/components/ProjectSelector.vue";
import { useSystemsStore } from "@/stores/systems";
import { useToastStore } from "@/stores/toast";
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
const systemsStore = useSystemsStore();
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
const isEditing = computed(() => editId.value !== null);
@@ -47,17 +51,66 @@ function removeLocation(i: number) {
locations.value.splice(i, 1);
}
const tagsText = ref("");
const projectId = ref<number | null>(null);
const systemIds = ref<number[]>([]);
const loading = ref(false);
const saving = ref(false);
const loadError = ref<string | null>(null);
const nameRef = ref<HTMLInputElement | null>(null);
// Systems belong to a project, so the picker only has anything to offer once
// one is chosen — and it repopulates when the project changes.
const projectSystems = computed(() =>
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
);
async function loadSystems() {
if (!projectId.value) return;
try {
await systemsStore.fetchSystems(projectId.value);
} catch {
/* non-fatal — the systems picker just won't populate */
}
}
watch(projectId, (next, prev) => {
// Moving to another project invalidates system ids from the old one.
if (prev !== null && next !== prev) systemIds.value = [];
loadSystems();
});
const canSave = computed(
() => !!form.value.name.trim() && !!form.value.code.trim() && !saving.value,
);
// A near-duplicate blocked on create: the same reusable thing is already
// recorded. Recording it twice is what merge then has to undo, so the editor
// offers the existing record before it offers to save anyway.
interface DuplicateHit {
existing_id: number;
existing_title: string;
}
const duplicate = ref<DuplicateHit | null>(null);
const forceCreate = ref(false);
function duplicateFrom(err: unknown): DuplicateHit | null {
if (!(err instanceof ApiError) || err.status !== 409) return null;
const body = err.body as Record<string, unknown>;
if (!body.duplicate) return null;
return {
existing_id: Number(body.existing_id),
existing_title: String(body.existing_title ?? "an existing snippet"),
};
}
async function load() {
if (!isEditing.value) {
// Recording from a project page carries the project through, so the snippet
// lands where the work is instead of unfiled.
if (route.query.projectId) {
projectId.value = Number(route.query.projectId);
await loadSystems();
}
await nextTick();
nameRef.value?.focus();
return;
@@ -82,6 +135,9 @@ async function load() {
tagsText.value = s.tags
.filter((t) => t !== "snippet" && t !== f.language)
.join(", ");
projectId.value = s.project_id ?? null;
systemIds.value = (s.systems ?? []).map((sys) => sys.id);
await loadSystems();
} catch {
loadError.value = "Couldn't load this snippet to edit.";
} finally {
@@ -115,20 +171,37 @@ async function save() {
when_to_use: form.value.when_to_use.trim(),
locations: cleanLocations(),
tags: parseTags(),
project_id: projectId.value,
system_ids: systemIds.value,
};
if (forceCreate.value) payload.force = true;
try {
const result = isEditing.value
? await updateSnippet(editId.value!, payload)
: await createSnippet(payload);
toast.show(isEditing.value ? "Snippet saved" : "Snippet created");
router.push(`/snippets/${result.id}`);
} catch {
} catch (err) {
// A blocked near-duplicate isn't a failure — it's the record telling you
// this already exists. Offer the existing one rather than a red toast.
const dup = duplicateFrom(err);
if (dup) {
duplicate.value = dup;
saving.value = false;
return;
}
toast.show("Failed to save snippet", "error");
} finally {
saving.value = false;
}
}
async function saveAnyway() {
duplicate.value = null;
forceCreate.value = true;
await save();
}
function cancel() {
if (isEditing.value) router.push(`/snippets/${editId.value}`);
else router.push("/snippets");
@@ -243,6 +316,41 @@ function cancel() {
<p class="hint">Extra tags. Language and snippet are added automatically.</p>
</div>
<div class="field">
<label for="sn-project">Project</label>
<ProjectSelector id="sn-project" v-model="projectId" />
<p class="hint">
Snippets surface proactively within their project; search reaches across all of them.
</p>
</div>
<div v-if="projectId" class="field">
<span class="field-label">Systems</span>
<div v-if="projectSystems.length" class="systems">
<label v-for="s in projectSystems" :key="s.id" class="system-opt">
<input type="checkbox" :value="s.id" v-model="systemIds" />
<span>{{ s.name }}</span>
</label>
</div>
<p v-else class="hint">No systems in this project yet.</p>
</div>
<div v-if="duplicate" class="duplicate" role="alert">
<p class="duplicate-title">This may already be recorded</p>
<p class="duplicate-body">
A similar snippet exists
<router-link :to="`/snippets/${duplicate.existing_id}`">
{{ duplicate.existing_title }}
</router-link
>. Reuse or edit that one rather than keeping two copies of the same thing.
</p>
<div class="duplicate-actions">
<button type="button" class="btn-secondary" @click="saveAnyway">
Record it anyway
</button>
</div>
</div>
<div class="actions">
<button type="button" class="btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn-primary" :disabled="!canSave">
@@ -403,6 +511,61 @@ function cancel() {
}
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text);
}
.systems {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 160px;
overflow-y: auto;
}
.system-opt {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
color: var(--color-text);
cursor: pointer;
}
.system-opt input {
accent-color: var(--color-primary);
cursor: pointer;
}
.duplicate {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning, var(--color-primary));
border-radius: 8px;
background: var(--color-bg-secondary);
}
.duplicate-title {
margin: 0;
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text);
}
.duplicate-body {
margin: 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.duplicate-body a {
color: var(--color-primary);
}
.duplicate-actions {
display: flex;
justify-content: flex-end;
}
.actions {
display: flex;
justify-content: flex-end;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.14",
"version": "0.1.15",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+15
View File
@@ -17,6 +17,9 @@ through recall/auto-inject; this skill is the active reflex around that.
- About to write a utility, hook, formatter, adapter, or a reusable component?
**Search snippets before writing it.** `list_snippets(q="…")` (or a plain
`search`) — a matching one may already exist, in this project or another.
`list_snippets` searches every project by default; that's deliberate, since a
helper you need here was quite possibly written somewhere else. Narrow with
`project_id` only when you specifically want this project's own.
- If a snippet fits, pull it in full with `get_snippet(id)` and reuse it — its
`location` points at the reference implementation. Adapt, don't re-derive.
- If auto-inject already surfaced a snippet title that looks relevant, that's
@@ -37,6 +40,18 @@ through recall/auto-inject; this skill is the active reflex around that.
per reusable thing. If it already exists, `update_snippet` it instead of
recording a second copy (the create gate will flag a near-duplicate anyway).
## Keep the record honest
A recorded snippet is offered as prior art on every matching turn, so a wrong
one costs more than a missing one.
- Details gone stale — a renamed symbol, a moved file, a signature that's
changed? Fix it with `update_snippet`. Passing an **empty string** clears a
field, so a wrong signature or location can be removed, not just written over.
- Recorded something that turned out not to be reusable, or that no longer
exists? Retire it with `delete_snippet` — it goes to the trash and can be
restored. Don't leave it competing for attention.
## Found the same thing in several places — unify it
When you notice the same reusable thing recorded (or written) as several
+4 -1
View File
@@ -223,7 +223,10 @@ project_id, system_ids) so a later session is offered it. Make when_to_use sharp
with update_snippet rather than recording a second copy; when the same reusable
thing already exists as several one-offs, unify them into one canonical record
with merge_snippets (it folds every call site in as a location and trashes the
duplicates).
duplicates). Keep the record honest: a snippet whose details have gone stale can
be corrected with update_snippet (an empty string clears a field), and one that
is wrong or obsolete should be retired with delete_snippet — a bad snippet keeps
being offered as prior art, which costs more than none at all.
When developing Scribe itself, honor its multi-user sharing ACL: scope every
read and mutation of user data by owner + shares — never assume a single
+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)
+56 -6
View File
@@ -16,7 +16,9 @@ from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import not_found, parse_pagination
from scribe.services import dedup as dedup_svc
from scribe.services import snippets as snippets_svc
from scribe.services import systems as systems_svc
from scribe.services.access import can_write_note
from scribe.services.notes import get_note_for_user
@@ -46,9 +48,13 @@ async def list_snippets_route():
uid = get_current_user_id()
q = request.args.get("q") or None
tag = request.args.get("tag", "")
try:
project_id = int(request.args.get("project_id", 0) or 0) or None
except (TypeError, ValueError):
project_id = None
limit, offset = parse_pagination()
items, total = await snippets_svc.list_snippets(
uid, q=q, tag=tag, limit=limit, offset=offset,
uid, q=q, tag=tag, limit=limit, offset=offset, project_id=project_id,
)
return jsonify({"snippets": items, "total": total})
@@ -63,6 +69,31 @@ async def create_snippet_route():
if not name or not code:
return jsonify({"error": "name and code are required"}), 400
project_id = data.get("project_id") or None
# Same near-duplicate gate the MCP create path applies: recording the same
# reusable thing twice is what merge then has to undo, so catch it here too.
# `force` is the deliberate override once the operator has seen the warning.
if not data.get("force"):
dup = await dedup_svc.find_duplicate_note(
uid,
snippets_svc.compose_title(name, data.get("when_to_use", "")),
snippets_svc.compose_body(
code=data.get("code", ""),
language=data.get("language", ""),
signature=data.get("signature", ""),
when_to_use=data.get("when_to_use", ""),
repo=data.get("repo", ""),
path=data.get("path", ""),
symbol=data.get("symbol", ""),
locations=data.get("locations"),
),
project_id=project_id,
is_task=False,
note_type=snippets_svc.SNIPPET_NOTE_TYPE,
)
if dup is not None:
return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409
note = await snippets_svc.create_snippet(
uid,
name=name,
@@ -77,7 +108,13 @@ async def create_snippet_route():
tags=data.get("tags"),
project_id=project_id,
)
return jsonify(snippets_svc.snippet_to_dict(note)), 201
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
out = snippets_svc.snippet_to_dict(note)
out["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
]
return jsonify(out), 201
@snippets_bp.route("/<int:snippet_id>", methods=["GET"])
@@ -90,6 +127,13 @@ async def get_snippet_route(snippet_id: int):
note, permission = loaded
data = snippets_svc.snippet_to_dict(note)
data["permission"] = permission
# Read the association as the OWNER: a shared reader isn't scoped to the
# owner's project, so their own id would come back empty (mirrors the
# write-as-owner pattern this module already uses).
data["systems"] = [
s.to_dict()
for s in await systems_svc.list_record_systems(note.user_id, snippet_id)
]
return jsonify(data)
@@ -115,12 +159,20 @@ async def update_snippet_route(snippet_id: int):
if "tags" in data:
kwargs["tags"] = data["tags"]
if "project_id" in data:
# A present-but-empty project_id is a deliberate detach, not "unchanged"
# — the service distinguishes the two via its UNSET sentinel.
kwargs["project_id"] = data["project_id"] or None
updated = await snippets_svc.update_snippet(owner_uid, snippet_id, **kwargs)
if updated is None:
return not_found("Snippet")
return jsonify(snippets_svc.snippet_to_dict(updated))
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(owner_uid, snippet_id, data["system_ids"])
out = snippets_svc.snippet_to_dict(updated)
out["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(owner_uid, snippet_id)
]
return jsonify(out)
@snippets_bp.route("/<int:snippet_id>/merge", methods=["POST"])
@@ -173,8 +225,6 @@ async def delete_snippet_route(snippet_id: int):
note, _ = loaded
if not await can_write_note(uid, snippet_id):
return jsonify({"error": "Permission denied"}), 403
from scribe.services.trash import delete as trash_delete
batch = await trash_delete(note.user_id, "note", snippet_id)
if batch is None:
if not await snippets_svc.delete_snippet(note.user_id, snippet_id):
return not_found("Snippet")
return "", 204
+11 -1
View File
@@ -59,21 +59,27 @@ async def query_knowledge(
q: str | None,
limit: int,
offset: int,
project_id: int | None = None,
) -> tuple[list[dict], int]:
"""Query knowledge objects (non-task notes) with filters.
`project_id` narrows to one project (None = every project).
Returns (items, total_count).
"""
# Semantic search path — scores take priority over sort
if q:
return await _semantic_knowledge_search(
user_id, q, note_type=note_type, tags=tags, limit=limit, offset=offset
user_id, q, note_type=note_type, tags=tags, limit=limit,
offset=offset, project_id=project_id,
)
async with async_session() as session:
base = select(Note).where(Note.user_id == user_id)
base = _apply_type_filter(base, note_type)
if project_id is not None:
base = base.where(Note.project_id == project_id)
for tag in tags:
base = base.where(Note.tags.contains([tag]))
@@ -104,6 +110,7 @@ async def _semantic_knowledge_search(
tags: list[str],
limit: int,
offset: int,
project_id: int | None = None,
) -> tuple[list[dict], int]:
"""Hybrid search: keyword matches first (title/body ILIKE), then semantic results.
@@ -130,6 +137,8 @@ async def _semantic_knowledge_search(
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
)
base = _apply_type_filter(base, note_type)
if project_id is not None:
base = base.where(Note.project_id == project_id)
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Title matches first, then body-only matches, newest first within each
@@ -152,6 +161,7 @@ async def _semantic_knowledge_search(
limit=min(200, limit * 4),
threshold=0.3,
is_task=is_task_filter,
project_id=project_id,
)
for _score, note in candidates:
if note.deleted_at is not None:
+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]:
+68 -1
View File
@@ -82,6 +82,73 @@ async def test_update_snippet_missing_raises():
await update_snippet(123, name="x")
@pytest.mark.asyncio
async def test_update_snippet_empty_string_clears_a_field():
# An omitted field must stay None ("leave alone"), but an explicit empty
# string has to reach the service as "" so a stale field can be removed.
updated = _fake_snippet()
with patch("scribe.services.snippets.update_snippet",
AsyncMock(return_value=updated)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet
await update_snippet(1, signature="")
kwargs = mock_update.await_args.kwargs
assert kwargs["signature"] == "" # cleared
assert kwargs["name"] is None # untouched
@pytest.mark.asyncio
async def test_update_snippet_project_id_conventions():
from scribe.services import snippets as snippets_svc
updated = _fake_snippet()
cases = {0: snippets_svc.UNSET, -1: None, 5: 5}
for given, expected in cases.items():
with patch("scribe.services.snippets.update_snippet",
AsyncMock(return_value=updated)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet
await update_snippet(1, project_id=given)
assert mock_update.await_args.kwargs["project_id"] is expected
@pytest.mark.asyncio
async def test_create_and_update_pass_locations_through():
locs = [{"repo": "a", "path": "a.py", "symbol": "f"},
{"repo": "b", "path": "b.py", "symbol": "g"}]
created = _fake_snippet()
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
patch("scribe.services.snippets.create_snippet",
AsyncMock(return_value=created)) as mock_create:
from scribe.mcp.tools.snippets import create_snippet
await create_snippet(name="f", code="x", locations=locs)
assert mock_create.await_args.kwargs["locations"] == locs
with patch("scribe.services.snippets.update_snippet",
AsyncMock(return_value=created)) as mock_update:
from scribe.mcp.tools.snippets import update_snippet
await update_snippet(1, locations=locs)
assert mock_update.await_args.kwargs["locations"] == locs
@pytest.mark.asyncio
async def test_delete_snippet_retires_or_raises():
from scribe.mcp.tools.snippets import delete_snippet
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=True)):
assert await delete_snippet(1) == {"deleted": True, "id": 1}
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=False)):
with pytest.raises(ValueError):
await delete_snippet(404)
@pytest.mark.asyncio
async def test_list_snippets_defaults_to_every_project():
with patch("scribe.services.snippets.list_snippets",
AsyncMock(return_value=([], 0))) as mock_list:
from scribe.mcp.tools.snippets import list_snippets
await list_snippets(q="debounce")
assert mock_list.await_args.kwargs["project_id"] is None
await list_snippets(q="debounce", project_id=3)
assert mock_list.await_args.kwargs["project_id"] == 3
@pytest.mark.asyncio
async def test_merge_snippets_requires_a_source():
from scribe.mcp.tools.snippets import merge_snippets
@@ -128,5 +195,5 @@ def test_register_attaches_all_tools():
snippets.register(FakeMcp())
assert set(names) == {
"list_snippets", "create_snippet", "get_snippet", "update_snippet",
"merge_snippets",
"delete_snippet", "merge_snippets",
}
+40 -1
View File
@@ -27,12 +27,51 @@ def test_snippet_handlers_callable():
def test_service_functions_take_user_id():
"""Routes must call snippet services with user_id — verify the contract."""
from scribe.services import snippets as svc
for fn_name in ("create_snippet", "list_snippets", "get_snippet", "update_snippet"):
for fn_name in (
"create_snippet", "list_snippets", "get_snippet", "update_snippet",
"delete_snippet", "merge_snippets",
):
fn = getattr(svc, fn_name)
assert callable(fn)
assert "user_id" in inspect.signature(fn).parameters
def test_agent_and_web_surfaces_stay_at_parity():
"""The MCP tools and the REST routes are two callers of one service; a
capability on one has to exist on the other (rule #33). This guard exists
because they drifted apart once: MCP had no delete or `locations`, and the
web side had no `system_ids` and no near-duplicate gate."""
from scribe.mcp.tools import snippets as tools
from scribe.routes import snippets as routes
# Every write verb the web surface offers, the agent surface offers too.
for verb in ("create", "get", "list", "update", "delete", "merge"):
assert callable(getattr(tools, f"{verb}_snippets", None) or
getattr(tools, f"{verb}_snippet", None)), f"MCP lacks {verb}"
# Multi-location records are reachable from both.
assert "locations" in inspect.signature(tools.create_snippet).parameters
assert "locations" in inspect.signature(tools.update_snippet).parameters
assert "locations" in inspect.getsource(routes.create_snippet_route)
assert "locations" in inspect.getsource(routes.update_snippet_route)
# System association and the duplicate gate reach both.
assert "system_ids" in inspect.signature(tools.create_snippet).parameters
for route in (routes.create_snippet_route, routes.update_snippet_route):
assert "system_ids" in inspect.getsource(route)
assert "find_duplicate_note" in inspect.getsource(routes.create_snippet_route)
def test_project_scoping_reaches_every_caller():
"""A snippet search has to be narrowable to one project from both surfaces."""
from scribe.mcp.tools import snippets as tools
from scribe.routes import snippets as routes
from scribe.services import snippets as svc
assert "project_id" in inspect.signature(svc.list_snippets).parameters
assert "project_id" in inspect.signature(tools.list_snippets).parameters
assert "project_id" in inspect.getsource(routes.list_snippets_route)
def test_update_field_map_matches_service_kwargs():
"""Every field the PATCH route forwards must be a real update_snippet kwarg
(rule #33 interface-contract parity)."""
+20
View File
@@ -62,6 +62,26 @@ def test_parse_falls_back_to_tag_for_language():
assert got["language"] == "ruby"
def test_parse_does_not_promote_a_caller_tag_to_language():
# compose_tags puts the language FIRST, so a leading "snippet" marker means
# no language was recorded — the tags after it are the caller's own and must
# not be mistaken for one (which would also corrupt the code fence on the
# next update).
tags = s.compose_tags("", ["auth"])
assert tags == ["snippet", "auth"]
got = s.parse_snippet_fields("n — u", s.compose_body(code="x = 1"), tags)
assert got["language"] == ""
def test_caller_tag_survives_an_update_round_trip_without_a_language():
# Regression: the tag used to be read back as the language, then dropped
# from the extra-tag set on re-compose — so it silently disappeared.
tags = s.compose_tags("", ["auth"])
fields = s.parse_snippet_fields("n — u", s.compose_body(code="x = 1"), tags)
extra = [t for t in tags if t not in (s.SNIPPET_TAG, fields["language"])]
assert s.compose_tags(fields["language"], extra) == ["snippet", "auth"]
def test_compose_body_multi_location_renders_bullet_list():
body = s.compose_body(
code="x = 1",