04b58ce01e
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 28s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Failing after 41s
CI & Build / Build & push image (push) Has been skipped
Narrows theb7d6fc7widening per the operator's call, and fixes a regression it introduced. Decision recorded as note 2094. Two scopes now, deliberately different: readable_notes_clause — everything the ACL permits, including records reached only via a direct/group note share. For EXPLICIT acts: a search the caller typed, a fetch by id. browsable_notes_clause — the caller's own records plus anything in a project they can reach. For PASSIVE surfaces: browse lists, facet counts, the process->skill manifest. The split is a trust boundary. Anything appearing unasked — in your own list, your own counts, or as a skill installed on your machine — reads as material you endorsed. A one-off someone shared with you hasn't earned that standing, so it waits until you go looking. This also dissolves the shared-Process problem by construction rather than by special case: the manifest is a passive surface, so a directly-shared Process is never installed as an auto-surfacing skill. Regression fix (#2093):b7d6fc7widened the list queries but left the fetch path owner-only, so on the MCP path a record could be listed and then not opened — get_snippet raised not-found, get_process couldn't resolve, and the manifest emitted stubs whose get_process call would fail. snippets.get_snippet and notes.resolve_process now resolve the read scope. delete_snippet gained an explicit can_write_note guard, since being able to SEE a shared snippet must not imply being able to bin it. Provenance, so nothing arrives looking like the operator's own work: - access.describe_provenance / label_shared_items add shared/owner/permission; labelling costs no query when everything is the caller's own. - MCP: get_snippet, list_snippets, get_process and list_processes carry it, and get_process now says outright NOT to follow a shared process verbatim — its follow-as-written contract was the sharpest instance of the problem. - The skill stub for a shared Process names its author and asks for a go-ahead, instead of describing it as "the operator's saved Scribe process". - Policy stated once in the MCP _INSTRUCTIONS and the reusing-code skill: a shared record is that person's suggestion, weigh it, attribute it, ask before adopting it. - UI: shared snippets show "by <owner>" in the list and a notice above the code in the detail view, reusing SharedWithMeView's vocabulary. Plugin 0.1.15 -> 0.1.16 (skill text changed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
263 lines
11 KiB
Python
263 lines
11 KiB
Python
"""Snippet MCP tools: record reusable functions/components for later recall.
|
|
|
|
A snippet is a Note with note_type='snippet' (see services/snippets.py). Because
|
|
snippets are ordinary embedded notes, once recorded they surface through the same
|
|
semantic search + title-first auto-inject as everything else — so a reusable
|
|
thing recorded once can be recalled before it's re-written as a one-off.
|
|
|
|
The tools wrap services/snippets.py, mirroring the note/process tools (dedup gate
|
|
on create, System association passthrough).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from scribe.mcp._context import current_user_id
|
|
from scribe.services import access as access_svc
|
|
from scribe.services import dedup as dedup_svc
|
|
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, project_id: int = 0,
|
|
) -> dict:
|
|
"""List recorded snippets (reusable functions/components).
|
|
|
|
Args:
|
|
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).
|
|
|
|
An entry marked `shared: true` with an `owner` belongs to someone else — one
|
|
person's suggestion, not settled practice here. Weigh it on its merits and
|
|
attribute it when you use it. Searching (passing `q`) also reaches snippets
|
|
shared directly with the operator; browsing without a query deliberately
|
|
does not, so those stay out of ambient results until asked for.
|
|
"""
|
|
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": await access_svc.label_shared_items(uid, items),
|
|
"total": total,
|
|
}
|
|
|
|
|
|
async def create_snippet(
|
|
name: str,
|
|
code: str,
|
|
language: str = "",
|
|
signature: str = "",
|
|
when_to_use: str = "",
|
|
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,
|
|
force: bool = False,
|
|
) -> dict:
|
|
"""Record a reusable function/component so future sessions can RECALL it
|
|
instead of writing a fresh one-off.
|
|
|
|
Reach for this the moment you build (or notice) something reusable: a helper,
|
|
a hook, a component, a pattern worth repeating. Recording it once makes it
|
|
surface automatically when a similar problem comes up later. Before writing a
|
|
new utility, search first — a snippet may already exist.
|
|
|
|
Args:
|
|
name: Short name of the function/component, e.g. "useDebouncedRef".
|
|
code: The code itself (required).
|
|
language: Language/format, e.g. "python", "vue", "sql". Becomes a tag and
|
|
the code-fence language.
|
|
signature: One-line signature/interface, e.g. "debounce(fn, ms) -> fn".
|
|
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.
|
|
system_ids: Ids of the project's Systems to associate this snippet with.
|
|
force: Bypass the near-duplicate gate (see below).
|
|
|
|
Returns the created snippet (including a parsed `snippet` field), OR — when a
|
|
near-duplicate snippet already exists and force is false — {"duplicate": true,
|
|
"existing_id": ..., "message": ...} and nothing is created. When that happens
|
|
and it really is the same reusable thing found in another place, prefer
|
|
merge_snippets(existing_id, [new...]) — or record then merge — to unify them
|
|
into ONE canonical record (which then carries every call site as a location),
|
|
rather than forcing a second copy with force=true.
|
|
"""
|
|
if not (name or "").strip() or not (code or "").strip():
|
|
raise ValueError("create_snippet requires a non-empty name and code")
|
|
uid = current_user_id()
|
|
|
|
title = snippets_svc.compose_title(name, when_to_use)
|
|
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(
|
|
uid, title, body, project_id=project_id or None,
|
|
is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE,
|
|
)
|
|
if dup is not None:
|
|
return dedup_svc.duplicate_response(dup, "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,
|
|
locations=locations, tags=tags, project_id=project_id or None,
|
|
)
|
|
if system_ids:
|
|
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
|
data = snippets_svc.snippet_to_dict(note)
|
|
if system_ids:
|
|
data["systems"] = [
|
|
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
|
]
|
|
return data
|
|
|
|
|
|
async def get_snippet(snippet_id: int) -> dict:
|
|
"""Fetch a snippet by id — the full record: code, signature, location, and a
|
|
parsed `snippet` field of its structured parts.
|
|
|
|
If the record belongs to someone else it carries `shared: true` with the
|
|
`owner` and your `permission`. Read that as ONE PERSON'S SUGGESTION, not as
|
|
established practice here: judge it on its merits, say whose it is when you
|
|
reference it, and don't adopt it as the house pattern without checking.
|
|
"""
|
|
uid = current_user_id()
|
|
note = await snippets_svc.get_snippet(uid, snippet_id)
|
|
if note is None:
|
|
raise ValueError(f"snippet {snippet_id} not found")
|
|
data = snippets_svc.snippet_to_dict(note)
|
|
data.update(await access_svc.describe_provenance(uid, note))
|
|
return data
|
|
|
|
|
|
async def update_snippet(
|
|
snippet_id: int,
|
|
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 the fields you pass change.
|
|
|
|
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=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")
|
|
if system_ids is not None:
|
|
await systems_svc.set_record_systems(uid, snippet_id, system_ids)
|
|
data = snippets_svc.snippet_to_dict(note)
|
|
if system_ids is not None:
|
|
data["systems"] = [
|
|
s.to_dict() for s in await systems_svc.list_record_systems(uid, snippet_id)
|
|
]
|
|
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.
|
|
|
|
Keeps the target as the canonical: its name, when-to-use, signature, language
|
|
and code win. The sources' locations and extra tags are folded in — so the
|
|
survivor ends up carrying EVERY call site as a location (which is itself the
|
|
"this is duplicated N times" signal) — and the source records are moved to the
|
|
trash (recoverable). The survivor is re-embedded so recall stops surfacing the
|
|
now-merged duplicates.
|
|
|
|
Args:
|
|
target_id: The snippet to keep (the canonical record).
|
|
source_ids: Snippet ids to fold into the target and retire. Ids that
|
|
aren't your snippets are skipped; target_id in the list is ignored.
|
|
|
|
Returns the merged canonical snippet (with a parsed `snippet` field) plus
|
|
`merged_ids` — the source ids actually merged and trashed.
|
|
"""
|
|
uid = current_user_id()
|
|
ids = [s for s in (source_ids or []) if s != target_id]
|
|
if not ids:
|
|
raise ValueError("merge_snippets requires at least one other source_id")
|
|
result = await snippets_svc.merge_snippets(uid, target_id, ids)
|
|
if result is None:
|
|
raise ValueError(f"snippet {target_id} not found")
|
|
note, merged_ids = result
|
|
data = snippets_svc.snippet_to_dict(note)
|
|
data["merged_ids"] = merged_ids
|
|
return data
|
|
|
|
|
|
def register(mcp) -> None:
|
|
for fn in (
|
|
list_snippets, create_snippet, get_snippet, update_snippet,
|
|
delete_snippet, merge_snippets,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|