feat(scribe): add snippet recall — note_type='snippet' service + MCP tools
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m7s
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m7s
Record reusable functions/components once so they surface via the existing semantic search + title-first auto-inject, instead of re-solving as one-offs. A snippet is a Note with note_type='snippet' (no schema change): note_type is free-text, and semantic_search_notes never filters by type, so snippets join the recall/auto-inject pool the moment they're embedded. Structured fields (name/language/signature/location/when_to_use/code) are stored via a body convention — title = "name — when to use" (what auto-inject surfaces), language + "snippet" as tags, templated markdown body — keeping storage swappable later without changing the tool/UI contract. - services/snippets.py: compose/parse helpers + create/get/list/update wrappers over notes_svc (dedup + System association reused). - mcp/tools/snippets.py: list_snippets / create_snippet / get_snippet / update_snippet, registered in tools/__init__.py. - unit tests for the serialize/parse round-trip and the MCP tool surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
This commit is contained in:
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from scribe.mcp.tools import (
|
||||
milestones, notes, processes, projects, recent, repos, rulebooks, search, systems, tags, tasks, trash,
|
||||
milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets, systems, tags, tasks, trash,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@ def register_all(mcp) -> None:
|
||||
recent.register(mcp)
|
||||
repos.register(mcp)
|
||||
processes.register(mcp)
|
||||
snippets.register(mcp)
|
||||
rulebooks.register(mcp)
|
||||
trash.register(mcp)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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 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) -> dict:
|
||||
"""List recorded snippets (reusable functions/components).
|
||||
|
||||
Args:
|
||||
q: Free-text search across name + body (optional).
|
||||
tag: Filter to a single tag, e.g. a language like "python" (optional).
|
||||
limit: Max results (1-100).
|
||||
|
||||
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).
|
||||
"""
|
||||
uid = current_user_id()
|
||||
items, total = await snippets_svc.list_snippets(
|
||||
uid, q=q or None, tag=tag, limit=max(1, min(limit, 100)),
|
||||
)
|
||||
return {"snippets": 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 = "",
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
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,
|
||||
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."""
|
||||
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")
|
||||
return snippets_svc.snippet_to_dict(note)
|
||||
|
||||
|
||||
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 = "",
|
||||
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()
|
||||
|
||||
def _n(v: str) -> str | None:
|
||||
return v if v != "" else None
|
||||
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (list_snippets, create_snippet, get_snippet, update_snippet):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
Reference in New Issue
Block a user