From 1942913366c7e20887fd80675cc6c82677e7c432 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Jul 2026 11:00:33 -0400 Subject: [PATCH 01/14] =?UTF-8?q?feat(scribe):=20add=20snippet=20recall=20?= =?UTF-8?q?=E2=80=94=20note=5Ftype=3D'snippet'=20service=20+=20MCP=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t --- src/scribe/mcp/tools/__init__.py | 3 +- src/scribe/mcp/tools/snippets.py | 162 +++++++++++++++++++ src/scribe/services/snippets.py | 257 +++++++++++++++++++++++++++++++ tests/test_mcp_tool_snippets.py | 100 ++++++++++++ tests/test_services_snippets.py | 77 +++++++++ 5 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 src/scribe/mcp/tools/snippets.py create mode 100644 src/scribe/services/snippets.py create mode 100644 tests/test_mcp_tool_snippets.py create mode 100644 tests/test_services_snippets.py diff --git a/src/scribe/mcp/tools/__init__.py b/src/scribe/mcp/tools/__init__.py index 5e9161c..b9b8dc3 100644 --- a/src/scribe/mcp/tools/__init__.py +++ b/src/scribe/mcp/tools/__init__.py @@ -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) diff --git a/src/scribe/mcp/tools/snippets.py b/src/scribe/mcp/tools/snippets.py new file mode 100644 index 0000000..8f15541 --- /dev/null +++ b/src/scribe/mcp/tools/snippets.py @@ -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) diff --git a/src/scribe/services/snippets.py b/src/scribe/services/snippets.py new file mode 100644 index 0000000..e6ef253 --- /dev/null +++ b/src/scribe/services/snippets.py @@ -0,0 +1,257 @@ +"""Snippet service — reusable functions/components recorded for recall. + +A *snippet* is a Note with ``note_type='snippet'``: a named, reusable function or +component recorded once so a later session can recall it before writing a +one-off. It carries a name, language, signature, canonical location +(repo · path · symbol), a one-line "when to reach for it", and the code itself. + +Structured fields are stored as a **body-convention** — no dedicated column, so +a snippet inherits everything a note has (embeddings, ACL, project/System +association, dedup) and, crucially, becomes eligible for semantic recall the +moment it's embedded: + + - ``title`` = ``"{name} — {when_to_use}"``. The title is exactly what the + title-first auto-inject surfaces, so this one line self-describes the snippet + in a recall menu. + - ``tags`` = ``[language, "snippet", *caller_tags]``. + - ``body`` = templated markdown (When to use / Signature / Location, then a + fenced code block). + +The public field API (name/language/signature/location/when_to_use/code) lives +here, so the underlying storage could later move to a structured column without +changing any caller (MCP tool, REST route, UI). +""" +from __future__ import annotations + +import re + +from scribe.services import knowledge as knowledge_svc +from scribe.services import notes as notes_svc + +SNIPPET_NOTE_TYPE = "snippet" +SNIPPET_TAG = "snippet" + + +# --- serialize: structured fields -> note (title/body/tags) ------------------ + +def compose_title(name: str, when_to_use: str = "") -> str: + """`name — when to use` (or just `name` when no usage note is given).""" + name = (name or "").strip() + when = (when_to_use or "").strip() + return f"{name} — {when}" if when else name + + +def compose_tags(language: str = "", tags: list[str] | None = None) -> list[str]: + """Language (lowercased) first, then the `snippet` marker, then caller tags — + de-duplicated, order preserved.""" + out: list[str] = [] + lang = (language or "").strip().lower() + if lang: + out.append(lang) + out.append(SNIPPET_TAG) + for t in tags or []: + t = (t or "").strip() + if t and t not in out: + out.append(t) + return out + + +def _location_line(repo: str = "", path: str = "", symbol: str = "") -> str: + parts = [p.strip() for p in (repo, path, symbol) if (p or "").strip()] + return " · ".join(f"`{p}`" for p in parts) + + +def compose_body( + *, + code: str, + language: str = "", + signature: str = "", + when_to_use: str = "", + repo: str = "", + path: str = "", + symbol: str = "", +) -> str: + """Render structured fields into the snippet body markdown. Empty fields are + omitted so the body stays clean.""" + header: list[str] = [] + if (when_to_use or "").strip(): + header.append(f"**When to use:** {when_to_use.strip()}") + if (signature or "").strip(): + header.append(f"**Signature:** `{signature.strip()}`") + loc = _location_line(repo, path, symbol) + if loc: + header.append(f"**Location:** {loc}") + fence_lang = (language or "").strip().lower() + code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```" + if header: + return "\n\n".join(header) + "\n\n" + code_block + "\n" + return code_block + "\n" + + +# --- parse: note -> structured fields (best-effort, never raises) ------------ + +_WHEN_RE = re.compile(r"^\*\*When to use:\*\*\s*(.+?)\s*$", re.MULTILINE) +_SIG_RE = re.compile(r"^\*\*Signature:\*\*\s*(.+?)\s*$", re.MULTILINE) +_LOC_RE = re.compile(r"^\*\*Location:\*\*\s*(.+?)\s*$", re.MULTILINE) +_CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL) + + +def parse_snippet_fields( + title: str, body: str, tags: list[str] | None = None +) -> dict: + """Recover structured fields from a snippet note. Tolerant by design: a field + that isn't present comes back empty and this never raises, so a hand-edited + body can't break the edit form.""" + title = title or "" + body = body or "" + name, _, when_from_title = title.partition(" — ") + fields = { + "name": name.strip(), + "when_to_use": when_from_title.strip(), + "signature": "", + "language": "", + "repo": "", + "path": "", + "symbol": "", + "code": "", + } + + m = _WHEN_RE.search(body) + if m: + fields["when_to_use"] = m.group(1).strip() + m = _SIG_RE.search(body) + if m: + fields["signature"] = m.group(1).strip().strip("`").strip() + m = _LOC_RE.search(body) + if m: + loc_parts = [p.strip().strip("`").strip() for p in m.group(1).split("·")] + for key, val in zip(("repo", "path", "symbol"), loc_parts): + fields[key] = val + m = _CODE_RE.search(body) + if m: + 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 + return fields + + +def snippet_to_dict(note) -> dict: + """Note serialization plus a parsed ``snippet`` sub-object of structured + fields, so callers get both the raw record and the typed view.""" + data = note.to_dict() + data["snippet"] = parse_snippet_fields(note.title, note.body, note.tags) + return data + + +# --- service wrappers over notes_svc ---------------------------------------- + +async def create_snippet( + user_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 | None = None, +): + """Create a snippet note. Returns the created Note.""" + return await notes_svc.create_note( + user_id, + title=compose_title(name, when_to_use), + body=compose_body( + code=code, language=language, signature=signature, + when_to_use=when_to_use, repo=repo, path=path, symbol=symbol, + ), + note_type=SNIPPET_NOTE_TYPE, + tags=compose_tags(language, tags), + project_id=project_id, + ) + + +async def get_snippet(user_id: int, snippet_id: int): + """Fetch a snippet by id, or None if it doesn't exist / isn't a snippet.""" + note = await notes_svc.get_note(user_id, snippet_id) + if note is None or note.note_type != SNIPPET_NOTE_TYPE: + return None + return note + + +async def list_snippets( + user_id: int, + *, + q: str | None = None, + tag: str = "", + limit: int = 50, + offset: int = 0, +) -> tuple[list[dict], int]: + """List snippets (id/title/tags/preview dicts), most-recently-updated first.""" + return await knowledge_svc.query_knowledge( + user_id=user_id, + note_type=SNIPPET_NOTE_TYPE, + tags=[tag] if tag else [], + sort="modified", + q=q, + limit=max(1, min(limit, 100)), + offset=max(0, offset), + ) + + +async def update_snippet( + user_id: int, + 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, + tags: list[str] | None = None, + project_id: int | None = None, +): + """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.""" + note = await notes_svc.get_note(user_id, snippet_id) + if note is None or note.note_type != SNIPPET_NOTE_TYPE: + return None + + cur = parse_snippet_fields(note.title, note.body, note.tags) + overlay = { + "name": name, "code": code, "language": language, "signature": signature, + "when_to_use": when_to_use, "repo": repo, "path": path, "symbol": symbol, + } + merged = {**cur, **{k: v for k, v in overlay.items() if v is not None}} + + fields: dict = { + "title": compose_title(merged["name"], merged["when_to_use"]), + "body": compose_body( + code=merged["code"], language=merged["language"], + signature=merged["signature"], when_to_use=merged["when_to_use"], + repo=merged["repo"], path=merged["path"], symbol=merged["symbol"], + ), + } + # Recompute tags: keep any non-language, non-marker tags the note already had + # (or the caller's replacement set), then re-derive language + marker. + existing_extra = [ + t for t in (note.tags or []) if t not in (SNIPPET_TAG, cur.get("language", "")) + ] + fields["tags"] = compose_tags( + merged["language"], tags if tags is not None else existing_extra + ) + if project_id is not None: + fields["project_id"] = project_id + + return await notes_svc.update_note(user_id, snippet_id, **fields) diff --git a/tests/test_mcp_tool_snippets.py b/tests/test_mcp_tool_snippets.py new file mode 100644 index 0000000..7ea6284 --- /dev/null +++ b/tests/test_mcp_tool_snippets.py @@ -0,0 +1,100 @@ +"""Tests for MCP snippet tools — patches the service layer.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from scribe.mcp._context import _user_id_ctx + + +@pytest.fixture(autouse=True) +def _bind_user(): + token = _user_id_ctx.set(7) + yield + _user_id_ctx.reset(token) + + +def _fake_snippet(): + n = MagicMock() + n.id = 1 + n.title = "debounce — rate-limit a callback" + n.body = "```js\nreturn 1\n```\n" + n.tags = ["js", "snippet"] + n.note_type = "snippet" + n.to_dict.return_value = { + "id": 1, "title": n.title, "note_type": "snippet", "tags": n.tags, + } + return n + + +@pytest.mark.asyncio +async def test_create_snippet_requires_name_and_code(): + from scribe.mcp.tools.snippets import create_snippet + with pytest.raises(ValueError): + await create_snippet(name="", code="x") + with pytest.raises(ValueError): + await create_snippet(name="x", code=" ") + + +@pytest.mark.asyncio +async def test_create_snippet_records_and_returns_parsed(): + 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 + out = await create_snippet( + name="debounce", code="return 1", language="js", + when_to_use="rate-limit a callback", + ) + assert out["note_type"] == "snippet" + assert out["snippet"]["name"] == "debounce" + assert out["snippet"]["language"] == "js" + assert mock_create.await_args.kwargs["name"] == "debounce" + + +@pytest.mark.asyncio +async def test_create_snippet_dedup_blocks_and_labels_snippet(): + dup = MagicMock(id=99) + with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=dup)), \ + patch("scribe.services.dedup.duplicate_response", + MagicMock(return_value={"duplicate": True, "existing_id": 99})) as mock_resp, \ + patch("scribe.services.snippets.create_snippet", AsyncMock()) as mock_create: + from scribe.mcp.tools.snippets import create_snippet + out = await create_snippet(name="debounce", code="return 1") + assert out["duplicate"] is True + mock_create.assert_not_awaited() + assert mock_resp.call_args.args[1] == "snippet" + + +@pytest.mark.asyncio +async def test_get_snippet_not_found_raises(): + with patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=None)): + from scribe.mcp.tools.snippets import get_snippet + with pytest.raises(ValueError): + await get_snippet(123) + + +@pytest.mark.asyncio +async def test_update_snippet_missing_raises(): + with patch("scribe.services.snippets.update_snippet", AsyncMock(return_value=None)): + from scribe.mcp.tools.snippets import update_snippet + with pytest.raises(ValueError): + await update_snippet(123, name="x") + + +def test_register_attaches_four_tools(): + from scribe.mcp.tools import snippets + names: list[str] = [] + + class FakeMcp: + def tool(self, name): + names.append(name) + + def deco(fn): + return fn + return deco + + snippets.register(FakeMcp()) + assert set(names) == { + "list_snippets", "create_snippet", "get_snippet", "update_snippet", + } diff --git a/tests/test_services_snippets.py b/tests/test_services_snippets.py new file mode 100644 index 0000000..3371484 --- /dev/null +++ b/tests/test_services_snippets.py @@ -0,0 +1,77 @@ +"""Unit tests for the snippet serialize/parse helpers (pure functions, no DB).""" +from scribe.services import snippets as s + + +def test_compose_title_with_and_without_usage(): + assert s.compose_title("debounce", "rate-limit a callback") == "debounce — rate-limit a callback" + assert s.compose_title(" debounce ", "") == "debounce" + assert s.compose_title("debounce") == "debounce" + + +def test_compose_tags_lowercases_language_and_dedups(): + assert s.compose_tags("Python", ["util", "python"]) == ["python", "snippet", "util"] + assert s.compose_tags("", None) == ["snippet"] + assert s.compose_tags("vue", ["snippet"]) == ["vue", "snippet"] + + +def test_compose_body_includes_fields_and_fence(): + body = s.compose_body( + code="return 1", language="python", signature="f() -> int", + when_to_use="always", repo="scribe", path="a.py", symbol="f", + ) + assert "**When to use:** always" in body + assert "**Signature:** `f() -> int`" in body + assert "`scribe` · `a.py` · `f`" in body + assert "```python\nreturn 1\n```" in body + assert body.rstrip().endswith("```") + + +def test_compose_body_bare_code_only(): + body = s.compose_body(code="x = 1") + assert body.strip() == "```\nx = 1\n```" + + +def test_parse_round_trips_a_composed_snippet(): + title = s.compose_title("useDebouncedRef", "debounce a reactive ref") + body = s.compose_body( + code="const x = 1", language="ts", signature="useDebouncedRef(v, ms)", + when_to_use="debounce a reactive ref", repo="scribe", + path="frontend/src/composables/x.ts", symbol="useDebouncedRef", + ) + got = s.parse_snippet_fields(title, body, ["ts", "snippet"]) + assert got["name"] == "useDebouncedRef" + assert got["when_to_use"] == "debounce a reactive ref" + assert got["signature"] == "useDebouncedRef(v, ms)" + assert got["language"] == "ts" + assert got["code"] == "const x = 1" + assert got["repo"] == "scribe" + assert got["path"] == "frontend/src/composables/x.ts" + assert got["symbol"] == "useDebouncedRef" + + +def test_parse_is_tolerant_of_plain_body(): + got = s.parse_snippet_fields("just a name", "no structure here", None) + assert got["name"] == "just a name" + assert got["when_to_use"] == "" + assert got["signature"] == "" + assert got["code"] == "" # never raises on an unstructured body + + +def test_parse_falls_back_to_tag_for_language(): + got = s.parse_snippet_fields("n — u", "```\ncode\n```", ["ruby", "snippet"]) + assert got["language"] == "ruby" + + +def test_snippet_to_dict_includes_parsed_fields(): + class FakeNote: + title = "debounce — rate-limit" + body = "```js\ncode\n```\n" + tags = ["js", "snippet"] + + def to_dict(self): + return {"id": 1, "title": self.title, "note_type": "snippet", "tags": self.tags} + + data = s.snippet_to_dict(FakeNote()) + assert data["snippet"]["name"] == "debounce" + assert data["snippet"]["language"] == "js" + assert data["snippet"]["code"] == "code" From 0ea3bff797c760e0933ad92346cfda1d75cfe7eb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Jul 2026 14:00:07 -0400 Subject: [PATCH 02/14] =?UTF-8?q?feat(scribe):=20snippet=20recording=20nud?= =?UTF-8?q?ge=20=E2=80=94=20reusing-code=20skill=20+=20MCP/SessionStart=20?= =?UTF-8?q?guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 of the Drafter recall milestone (#227): teach agents the two snippet reflexes — search recorded snippets before writing a new helper/util/component, and record something reusable the moment it's built — via the app's own instruction surfaces, not a Scribe rule (project rule #119). All instance-agnostic (rule #115). - plugin/skills/reusing-code/SKILL.md: new auto-surfacing process-skill covering both reflexes (recall-before-rebuild + record-when-reusable). - src/scribe/mcp/server.py: a Snippets paragraph in the MCP _INSTRUCTIONS. - plugin/hooks/scribe_static_context.md: a "reuse before rebuilding" bullet in the SessionStart static context. - plugin/.claude-plugin/plugin.json: version 0.1.12 -> 0.1.13 in the same change so the autoUpdate marketplace ships it (the #1040 lesson); description skill list updated. - plugin/README.md: trued the process-skill list to what actually ships. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t --- plugin/.claude-plugin/plugin.json | 4 +-- plugin/README.md | 5 +-- plugin/hooks/scribe_static_context.md | 5 +++ plugin/skills/reusing-code/SKILL.md | 45 +++++++++++++++++++++++++++ src/scribe/mcp/server.py | 13 ++++++++ 5 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 plugin/skills/reusing-code/SKILL.md diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index dc90135..2f925e7 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.12", + "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.13", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/README.md b/plugin/README.md index b6ab7ff..9a76a44 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -7,8 +7,9 @@ instance into a first-class Claude Code extension: rulebook (the `scribe` server). - **Session-start push channel** — a `SessionStart` hook injects your always-on rules + active-project context so Scribe surfaces *without being asked*. -- **Universal process-skills** — brainstorm, systematic-debugging, TDD, - writing-plans, verification, receiving-code-review (replaces superpowers). +- **Universal process-skills** — using-scribe, writing-plans, + systematic-debugging, verification, brainstorming, reusing-code (record and + recall reusable code as snippets). Replaces superpowers. - **Your Scribe Processes as skills** — saved Processes are synced into local `~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the stub fetches the live procedure via `get_process`. Refreshed each session and diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 180ecf1..dd5a313 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -37,6 +37,11 @@ for the operator's work, and as your own working memory across sessions. moment it's complete. When you **fix** something — even in passing — record it as its own issue (`create_task(kind="issue")`), not as a work-log line on an unrelated open task. +- **Reuse before rebuilding** — before writing a new helper/utility/component, + search recorded **snippets** (reusable code recorded once for recall) and + reuse the prior art instead of re-solving it; when you build something + reusable, record it with `create_snippet` (name, code, when-to-reach-for-it, + location) so a later session is offered it, not left to write it again. - Do **not** keep the operator's rules, plans, or project notes in local memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy. - **Compact at clean seams** — because you record as you go, a context diff --git a/plugin/skills/reusing-code/SKILL.md b/plugin/skills/reusing-code/SKILL.md new file mode 100644 index 0000000..cf1cccd --- /dev/null +++ b/plugin/skills/reusing-code/SKILL.md @@ -0,0 +1,45 @@ +--- +name: reusing-code +description: Use when you're about to write a helper, utility, hook, or reusable component — search recorded snippets FIRST so prior art is reused instead of re-solved. And the moment you build or notice something reusable, record it as a snippet so a later session finds it. Triggers on "write a util/helper", "I need a function that…", "let me add a component", or just having built something worth reusing. +--- + +# Reusing code — recall before you rebuild + +Reusable code is worth writing once. Scribe stores **snippets** — a named, +reusable function or component recorded with its language, signature, canonical +location (repo · path · symbol), a one-line *"when to reach for it,"* and the +code itself — so prior art can surface *before* it's re-written as a one-off. +Snippets are ordinary embedded notes, so a recorded one also surfaces on its own +through recall/auto-inject; this skill is the active reflex around that. + +## Before you write a new helper — search first + +- 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. +- 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 + your cue to `get_snippet` it rather than start from scratch. + +## The moment you build something reusable — record it + +- Just wrote (or noticed) a helper, hook, pattern, or component worth repeating? + Record it with `create_snippet` while it's fresh: + - **name** — what it's called, e.g. `useDebouncedRef`. + - **code** — the implementation. + - **when_to_use** — one sharp line on when to reach for it. This becomes part + of the title, so it's what a later recall menu shows — make it earn the pull. + - **language**, **signature**, and **location** (`repo` / `path` / `symbol`) + so the recorded copy points back at the canonical source. + - **project_id** / **system_ids** to associate it with the work it belongs to. +- Record the *reference* implementation, not every call site — one good entry + 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). + +## Why this pays off + +A one-off written a second time is the cost this avoids. Recording a snippet +once — with a location and a crisp "when to use" — means the next session is +offered the prior art instead of re-solving it. Search before writing; record +what's worth reusing. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 44be3af..5c1f8ca 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -209,6 +209,19 @@ get_process(name) and follow the returned prompt verbatim, including any "clarify first" steps it contains. Author a new one with create_process(title, body); edit with update_process. +Scribe also stores Snippets — reusable functions/components recorded once for +recall (note_type "snippet"): a name, language, signature, canonical location +(repo · path · symbol), a one-line "when to reach for it", and the code. They +are ordinary embedded notes, so a recorded snippet also surfaces through the +same search + proactive recall as everything else. Two reflexes: (1) before you +write a new helper/utility/component, search first (list_snippets(q=...) or +search) — reuse the prior art with get_snippet(id) instead of re-deriving a +one-off; (2) the moment you build or notice something reusable, record it with +create_snippet(name, code, when_to_use, language, signature, repo, path, symbol, +project_id, system_ids) so a later session is offered it. Make when_to_use sharp +— it becomes the title, which is what a recall menu shows. Edit an existing one +with update_snippet rather than recording a second copy. + 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 operator. "Works for one user" is not done. From d257c0fd67d96a073c92b84884eccb39c40ac7e9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Jul 2026 14:16:02 -0400 Subject: [PATCH 03/14] feat(scribe): snippet management UI + REST routes; embed snippets on create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 of the Drafter recall milestone (#227): a human-facing surface for the reusable-code snippets that agents record via MCP, plus the REST API behind it. Backend embeds snippets inline on create/update so they're recallable immediately, not only after a restart. Backend: - routes/snippets.py: GET/POST /api/snippets, GET/PATCH/DELETE /api/snippets/. Share-aware per rule #78 (get_note_for_user + can_write_note), writes performed as the owner; list owner-scoped — mirrors routes/notes.py. Registered in app.py. - services/snippets.py: embed on create/update via a _embed_snippet fire-and-forget helper, covering BOTH the MCP tool and the REST route by construction. A snippet's value is immediate recall, so it can't wait for the startup-only backfill (see issue: MCP create path doesn't embed inline for notes/tasks generally). - tests/test_routes_snippets.py: structural registration + handler/service contract + PATCH-field ↔ update_snippet-kwarg parity (rule #33). Frontend (Vue 3 + TS): - api/snippets.ts: typed client, modeled on api/systems.ts. - views: SnippetListView (search, skeleton/empty/error states), SnippetDetailView (read + copy-to-clipboard, ConfirmDialog delete), SnippetEditorView (create/edit all fields, Ctrl/Cmd+S, Esc, autofocus, validation). v1 quality per rules #24/#27. - router: /snippets, /snippets/new, /snippets/:id, /snippets/:id/edit. - NoteType union widened to include 'snippet'; Snippets nav link added to AppHeader (desktop pill bar + mobile menu). - Design system: Moss --color-action-primary for action buttons, accent --color-primary reserved for tags/brand (Hybrid rule); focus rings; JetBrains Mono for code/name/signature/location. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t --- frontend/src/api/snippets.ts | 87 +++++ frontend/src/components/AppHeader.vue | 2 + frontend/src/router/index.ts | 20 ++ frontend/src/types/note.ts | 2 +- frontend/src/views/SnippetDetailView.vue | 316 ++++++++++++++++++ frontend/src/views/SnippetEditorView.vue | 394 +++++++++++++++++++++++ frontend/src/views/SnippetListView.vue | 321 ++++++++++++++++++ src/scribe/app.py | 2 + src/scribe/routes/snippets.py | 137 ++++++++ src/scribe/services/snippets.py | 33 +- tests/test_routes_snippets.py | 45 +++ 11 files changed, 1355 insertions(+), 4 deletions(-) create mode 100644 frontend/src/api/snippets.ts create mode 100644 frontend/src/views/SnippetDetailView.vue create mode 100644 frontend/src/views/SnippetEditorView.vue create mode 100644 frontend/src/views/SnippetListView.vue create mode 100644 src/scribe/routes/snippets.py create mode 100644 tests/test_routes_snippets.py diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts new file mode 100644 index 0000000..ea5b5a0 --- /dev/null +++ b/frontend/src/api/snippets.ts @@ -0,0 +1,87 @@ +import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; + +/** Structured fields parsed out of a snippet note (mirrors the backend + * `parse_snippet_fields` — see services/snippets.py). */ +export interface SnippetFields { + name: string; + when_to_use: string; + signature: string; + language: string; + repo: string; + path: string; + symbol: string; + code: string; +} + +/** A full snippet record: the note dict plus the parsed `snippet` sub-object, + * as returned by the backend `snippet_to_dict`. */ +export interface Snippet { + id: number; + title: string; + body: string; + tags: string[]; + note_type: string; + project_id: number | null; + permission?: string; + created_at: string; + updated_at: string; + snippet: SnippetFields; +} + +/** Lightweight list item from the knowledge preview feed. Note: the `snippet` + * field here is a truncated *body preview* (the knowledge feed's naming), not + * the parsed fields above. */ +export interface SnippetListItem { + id: number; + title: string; + tags: string[]; + note_type: string; + snippet: string; + created_at: string; + updated_at: string; +} + +/** Create/update payload — discrete fields the backend serializes into the + * note (title/body/tags). Empty strings are applied; omitted keys are left + * unchanged on update. */ +export interface SnippetInput { + name: string; + code: string; + language?: string; + signature?: string; + when_to_use?: string; + repo?: string; + path?: string; + symbol?: string; + tags?: string[]; + project_id?: number | null; +} + +export async function listSnippets( + params: { q?: string; tag?: string } = {}, +): 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); + const query = qs.toString(); + return apiGet(`/api/snippets${query ? `?${query}` : ""}`); +} + +export async function getSnippet(id: number): Promise { + return apiGet(`/api/snippets/${id}`); +} + +export async function createSnippet(data: SnippetInput): Promise { + return apiPost("/api/snippets", data); +} + +export async function updateSnippet( + id: number, + data: Partial, +): Promise { + return apiPatch(`/api/snippets/${id}`, data); +} + +export async function deleteSnippet(id: number): Promise { + return apiDelete(`/api/snippets/${id}`); +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 06e41b3..37c567b 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -48,6 +48,7 @@ router.afterEach(() => { Dashboard Browse Projects + Snippets Rulebooks @@ -93,6 +94,7 @@ router.afterEach(() => { Dashboard Browse Projects + Snippets Rulebooks Shared
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 87d1771..43de4dd 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -69,6 +69,26 @@ const router = createRouter({ name: "note-edit", component: () => import("@/views/NoteEditorView.vue"), }, + { + path: "/snippets", + name: "snippets", + component: () => import("@/views/SnippetListView.vue"), + }, + { + path: "/snippets/new", + name: "snippet-new", + component: () => import("@/views/SnippetEditorView.vue"), + }, + { + path: "/snippets/:id", + name: "snippet-view", + component: () => import("@/views/SnippetDetailView.vue"), + }, + { + path: "/snippets/:id/edit", + name: "snippet-edit", + component: () => import("@/views/SnippetEditorView.vue"), + }, { path: "/graph", name: "graph", diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index 517c542..01f269f 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -3,7 +3,7 @@ import type { System } from "@/api/systems"; export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled"; export type TaskPriority = "none" | "low" | "medium" | "high"; export type TaskKind = "work" | "plan" | "issue"; -export type NoteType = "note" | "process"; +export type NoteType = "note" | "process" | "snippet"; export interface Note { id: number; diff --git a/frontend/src/views/SnippetDetailView.vue b/frontend/src/views/SnippetDetailView.vue new file mode 100644 index 0000000..9dca0dd --- /dev/null +++ b/frontend/src/views/SnippetDetailView.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/frontend/src/views/SnippetEditorView.vue b/frontend/src/views/SnippetEditorView.vue new file mode 100644 index 0000000..3151983 --- /dev/null +++ b/frontend/src/views/SnippetEditorView.vue @@ -0,0 +1,394 @@ + + + + + diff --git a/frontend/src/views/SnippetListView.vue b/frontend/src/views/SnippetListView.vue new file mode 100644 index 0000000..f944da5 --- /dev/null +++ b/frontend/src/views/SnippetListView.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/src/scribe/app.py b/src/scribe/app.py index 7f45626..1c67cc9 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -29,6 +29,7 @@ from scribe.routes.plugin import plugin_bp from scribe.routes.trash import trash_bp from scribe.routes.dashboard import dashboard_bp from scribe.routes.systems import systems_bp +from scribe.routes.snippets import snippets_bp from scribe.mcp import mount_mcp STATIC_DIR = Path(__file__).parent / "static" @@ -91,6 +92,7 @@ def create_app() -> Quart: app.register_blueprint(trash_bp) app.register_blueprint(dashboard_bp) app.register_blueprint(systems_bp) + app.register_blueprint(snippets_bp) @app.before_request async def before_request(): diff --git a/src/scribe/routes/snippets.py b/src/scribe/routes/snippets.py new file mode 100644 index 0000000..56d0aac --- /dev/null +++ b/src/scribe/routes/snippets.py @@ -0,0 +1,137 @@ +"""REST routes for snippets — reusable functions/components recorded for recall. + +A snippet is a note with note_type='snippet' (see services/snippets.py). These +routes feed the web management UI; the MCP tools (mcp/tools/snippets.py) are the +agent-facing surface. Both go through services/snippets.py, so the +serialize/parse contract and embedding-on-create live in one place (DRY). + +ACL (rule #78): reads/writes of a single snippet resolve through the share-aware +`get_note_for_user` + `can_write_note`, and writes are performed as the OWNER so +a shared editor isn't rejected by the owner-scoped service — mirroring +routes/notes.py. The list is owner-scoped, matching the note-browse surface. +""" +import logging + +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 snippets as snippets_svc +from scribe.services.access import can_write_note +from scribe.services.notes import get_note_for_user + +logger = logging.getLogger(__name__) + +snippets_bp = Blueprint("snippets", __name__, url_prefix="/api/snippets") + +# Fields the create/update payload may carry, mapped straight to the service. +_STR_FIELDS = ("name", "code", "language", "signature", "when_to_use", "repo", "path", "symbol") + + +async def _load_snippet(uid: int, snippet_id: int): + """Share-aware resolve of a snippet by id → (note, permission) or None if it + isn't accessible or isn't a snippet.""" + result = await get_note_for_user(uid, snippet_id) + if result is None: + return None + note, permission = result + if note.note_type != snippets_svc.SNIPPET_NOTE_TYPE: + return None + return note, permission + + +@snippets_bp.route("", methods=["GET"]) +@login_required +async def list_snippets_route(): + uid = get_current_user_id() + q = request.args.get("q") or None + tag = request.args.get("tag", "") + limit, offset = parse_pagination() + items, total = await snippets_svc.list_snippets( + uid, q=q, tag=tag, limit=limit, offset=offset, + ) + return jsonify({"snippets": items, "total": total}) + + +@snippets_bp.route("", methods=["POST"]) +@login_required +async def create_snippet_route(): + uid = get_current_user_id() + data = await request.get_json() or {} + name = (data.get("name") or "").strip() + code = (data.get("code") or "").strip() + if not name or not code: + return jsonify({"error": "name and code are required"}), 400 + project_id = data.get("project_id") or None + note = await snippets_svc.create_snippet( + uid, + name=name, + 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", ""), + tags=data.get("tags"), + project_id=project_id, + ) + return jsonify(snippets_svc.snippet_to_dict(note)), 201 + + +@snippets_bp.route("/", methods=["GET"]) +@login_required +async def get_snippet_route(snippet_id: int): + uid = get_current_user_id() + loaded = await _load_snippet(uid, snippet_id) + if loaded is None: + return not_found("Snippet") + note, permission = loaded + data = snippets_svc.snippet_to_dict(note) + data["permission"] = permission + return jsonify(data) + + +@snippets_bp.route("/", methods=["PATCH"]) +@login_required +async def update_snippet_route(snippet_id: int): + uid = get_current_user_id() + loaded = await _load_snippet(uid, snippet_id) + if loaded is None: + return not_found("Snippet") + note, _ = loaded + if not await can_write_note(uid, snippet_id): + return jsonify({"error": "Permission denied"}), 403 + owner_uid = note.user_id + data = await request.get_json() or {} + + # Partial update: only keys present in the payload change (the service + # treats None as "leave unchanged"). Empty strings ARE applied — the form + # sends the full field set, so a cleared field is an intentional clear. + kwargs = {k: data[k] for k in _STR_FIELDS if k in data} + if "tags" in data: + kwargs["tags"] = data["tags"] + if "project_id" in data: + 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)) + + +@snippets_bp.route("/", methods=["DELETE"]) +@login_required +async def delete_snippet_route(snippet_id: int): + uid = get_current_user_id() + loaded = await _load_snippet(uid, snippet_id) + if loaded is None: + return not_found("Snippet") + 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: + return not_found("Snippet") + return "", 204 diff --git a/src/scribe/services/snippets.py b/src/scribe/services/snippets.py index e6ef253..c1203f1 100644 --- a/src/scribe/services/snippets.py +++ b/src/scribe/services/snippets.py @@ -23,6 +23,7 @@ changing any caller (MCP tool, REST route, UI). """ from __future__ import annotations +import asyncio import re from scribe.services import knowledge as knowledge_svc @@ -32,6 +33,25 @@ SNIPPET_NOTE_TYPE = "snippet" SNIPPET_TAG = "snippet" +def _embed_snippet(note) -> None: + """Fire-and-forget embedding refresh for a snippet. + + A snippet's whole value is *immediate* recall — it must join the semantic / + auto-inject pool the moment it's recorded, not wait for the startup backfill. + Unlike a plain note (embedded at the REST-route boundary only, so its MCP + create path defers to restart-backfill), a snippet is recorded primarily via + MCP, so we embed here in the service — covering BOTH the MCP tool and the + REST route by construction. Mirrors the route pattern: fire-and-forget, + text = title + body. Import lazily so the pure serialize/parse helpers can be + imported without pulling in the embedding model. + """ + text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "") + if not text: + return + from scribe.services.embeddings import upsert_note_embedding + asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text)) + + # --- serialize: structured fields -> note (title/body/tags) ------------------ def compose_title(name: str, when_to_use: str = "") -> str: @@ -164,8 +184,9 @@ async def create_snippet( tags: list[str] | None = None, project_id: int | None = None, ): - """Create a snippet note. Returns the created Note.""" - return await notes_svc.create_note( + """Create a snippet note (embedded on create for immediate recall). Returns + the created Note.""" + note = await notes_svc.create_note( user_id, title=compose_title(name, when_to_use), body=compose_body( @@ -176,6 +197,8 @@ async def create_snippet( tags=compose_tags(language, tags), project_id=project_id, ) + _embed_snippet(note) + return note async def get_snippet(user_id: int, snippet_id: int): @@ -254,4 +277,8 @@ async def update_snippet( if project_id is not None: fields["project_id"] = project_id - return await notes_svc.update_note(user_id, snippet_id, **fields) + updated = await notes_svc.update_note(user_id, snippet_id, **fields) + if updated is not None: + # Title/body changed → refresh the embedding so recall reflects the edit. + _embed_snippet(updated) + return updated diff --git a/tests/test_routes_snippets.py b/tests/test_routes_snippets.py new file mode 100644 index 0000000..79b0387 --- /dev/null +++ b/tests/test_routes_snippets.py @@ -0,0 +1,45 @@ +"""Structural tests for the snippets blueprint — registration + handler/service +contracts. Full HTTP integration needs a live DB + auth the unit env lacks.""" +import inspect + + +def test_snippets_blueprint_registered(): + from scribe.routes.snippets import snippets_bp + assert snippets_bp.name == "snippets" + assert snippets_bp.url_prefix == "/api/snippets" + + +def test_snippets_blueprint_registered_in_app(): + from scribe.app import create_app + app = create_app() + assert "snippets" in app.blueprints + + +def test_snippet_handlers_callable(): + from scribe.routes import snippets as routes + for name in ( + "list_snippets_route", "create_snippet_route", "get_snippet_route", + "update_snippet_route", "delete_snippet_route", + ): + assert callable(getattr(routes, name)) + + +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"): + fn = getattr(svc, fn_name) + assert callable(fn) + assert "user_id" in inspect.signature(fn).parameters + + +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).""" + from scribe.routes import snippets as routes + from scribe.services import snippets as svc + params = inspect.signature(svc.update_snippet).parameters + for field in routes._STR_FIELDS: + assert field in params, f"update_snippet has no '{field}' kwarg" + assert "tags" in params + assert "project_id" in params From eb400a521b1c425c777bc0fa684b0e82719661c9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Jul 2026 17:07:09 -0400 Subject: [PATCH 04/14] feat(scribe): multi-location snippet body convention (backward compatible) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the snippet-merge milestone (#231). A snippet that unifies N found one-offs carries N locations (one per call site), so `location` becomes a list. Ships on the body-convention — no migration, swappable to the deferred `data` JSONB later (as #227 decision 5 anticipated). - compose_body: renders `**Location:**` for a single location, a `**Locations:**` bullet list for several. Accepts a `locations` list; the single repo/path/symbol params remain as a one-location shorthand (create path + existing callers/tests unchanged). - parse_snippet_fields: reads BOTH the new `**Locations:**` list block AND the legacy single `**Location:**` line (tolerant, never raises); returns a `locations` list and mirrors the first into repo/path/symbol for back-compat (rule #33). - update_snippet: gains a `locations` param — replaces the whole set; else a legacy single triple overlays onto the first location; else kept. - Tests: multi-location round-trip, singular-vs-plural label, legacy single-line parse, normalize dedup/empty. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t --- src/scribe/services/snippets.py | 132 ++++++++++++++++++++++++++++---- tests/test_services_snippets.py | 60 +++++++++++++++ 2 files changed, 176 insertions(+), 16 deletions(-) diff --git a/src/scribe/services/snippets.py b/src/scribe/services/snippets.py index c1203f1..e1cdcac 100644 --- a/src/scribe/services/snippets.py +++ b/src/scribe/services/snippets.py @@ -76,9 +76,43 @@ def compose_tags(language: str = "", tags: list[str] | None = None) -> list[str] return out -def _location_line(repo: str = "", path: str = "", symbol: str = "") -> str: - parts = [p.strip() for p in (repo, path, symbol) if (p or "").strip()] - return " · ".join(f"`{p}`" for p in parts) +def _normalize_locations(locations: list[dict] | None) -> list[dict]: + """Clean a list of {repo,path,symbol} locations: strip fields, drop wholly + empty entries, de-duplicate identical ones (order preserved). A merged + snippet carries several locations (one per call site); a fresh one carries + at most one.""" + out: list[dict] = [] + seen: set[tuple[str, str, str]] = set() + for loc in locations or []: + repo = (loc.get("repo") or "").strip() + path = (loc.get("path") or "").strip() + symbol = (loc.get("symbol") or "").strip() + if not (repo or path or symbol): + continue + key = (repo, path, symbol) + if key in seen: + continue + seen.add(key) + out.append({"repo": repo, "path": path, "symbol": symbol}) + return out + + +def _location_str(loc: dict) -> str: + """`repo` · `path` · `symbol` — only the non-empty parts.""" + parts = [(loc.get(k) or "").strip() for k in ("repo", "path", "symbol")] + return " · ".join(f"`{p}`" for p in parts if p) + + +def _render_location_block(locations: list[dict]) -> str | None: + """One `**Location:**` line for a single location; a `**Locations:**` bullet + list for several. None when there are none.""" + locs = [loc for loc in locations if _location_str(loc)] + if not locs: + return None + if len(locs) == 1: + return f"**Location:** {_location_str(locs[0])}" + lines = "\n".join(f"- {_location_str(loc)}" for loc in locs) + return f"**Locations:**\n{lines}" def compose_body( @@ -90,17 +124,28 @@ def compose_body( repo: str = "", path: str = "", symbol: str = "", + locations: list[dict] | None = None, ) -> str: """Render structured fields into the snippet body markdown. Empty fields are - omitted so the body stays clean.""" + omitted so the body stays clean. + + Locations: pass ``locations`` (a list of {repo,path,symbol}) for the general + multi-location case; the single ``repo``/``path``/``symbol`` params remain as + a back-compat shorthand for one location and are used only when ``locations`` + is not given. + """ + if locations is None: + locations = [{"repo": repo, "path": path, "symbol": symbol}] + locs = _normalize_locations(locations) + header: list[str] = [] if (when_to_use or "").strip(): header.append(f"**When to use:** {when_to_use.strip()}") if (signature or "").strip(): header.append(f"**Signature:** `{signature.strip()}`") - loc = _location_line(repo, path, symbol) - if loc: - header.append(f"**Location:** {loc}") + loc_block = _render_location_block(locs) + if loc_block: + header.append(loc_block) fence_lang = (language or "").strip().lower() code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```" if header: @@ -113,15 +158,33 @@ def compose_body( _WHEN_RE = re.compile(r"^\*\*When to use:\*\*\s*(.+?)\s*$", re.MULTILINE) _SIG_RE = re.compile(r"^\*\*Signature:\*\*\s*(.+?)\s*$", re.MULTILINE) _LOC_RE = re.compile(r"^\*\*Location:\*\*\s*(.+?)\s*$", re.MULTILINE) +_LOCS_RE = re.compile( + r"^\*\*Locations:\*\*[ \t]*\n((?:[ \t]*-[ \t]*.+\n?)+)", re.MULTILINE +) _CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL) +def _parse_location_str(s: str) -> dict | None: + """Parse a `repo` · `path` · `symbol` fragment back into a location dict, or + None if it's empty.""" + raw = [p.strip().strip("`").strip() for p in (s or "").split("·")] + repo = raw[0] if len(raw) > 0 else "" + path = raw[1] if len(raw) > 1 else "" + symbol = raw[2] if len(raw) > 2 else "" + if not (repo or path or symbol): + return None + return {"repo": repo, "path": path, "symbol": symbol} + + def parse_snippet_fields( title: str, body: str, tags: list[str] | None = None ) -> dict: """Recover structured fields from a snippet note. Tolerant by design: a field that isn't present comes back empty and this never raises, so a hand-edited - body can't break the edit form.""" + body can't break the edit form. + + ``locations`` is a list of {repo,path,symbol}; ``repo``/``path``/``symbol`` + mirror the FIRST location for back-compat with the single-location callers.""" title = title or "" body = body or "" name, _, when_from_title = title.partition(" — ") @@ -133,6 +196,7 @@ def parse_snippet_fields( "repo": "", "path": "", "symbol": "", + "locations": [], "code": "", } @@ -142,11 +206,30 @@ def parse_snippet_fields( m = _SIG_RE.search(body) if m: fields["signature"] = m.group(1).strip().strip("`").strip() - m = _LOC_RE.search(body) + + # Locations: prefer the multi-location `**Locations:**` bullet list, else the + # legacy single `**Location:**` line. + locations: list[dict] = [] + m = _LOCS_RE.search(body) if m: - loc_parts = [p.strip().strip("`").strip() for p in m.group(1).split("·")] - for key, val in zip(("repo", "path", "symbol"), loc_parts): - fields[key] = val + for line in m.group(1).splitlines(): + line = line.strip() + if line.startswith("-"): + loc = _parse_location_str(line[1:]) + if loc: + locations.append(loc) + else: + m = _LOC_RE.search(body) + if m: + loc = _parse_location_str(m.group(1)) + if loc: + locations.append(loc) + fields["locations"] = locations + if locations: + fields["repo"] = locations[0]["repo"] + fields["path"] = locations[0]["path"] + fields["symbol"] = locations[0]["symbol"] + m = _CODE_RE.search(body) if m: fields["language"] = m.group(1).strip() @@ -241,29 +324,46 @@ async def update_snippet( 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 | None = None, ): """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.""" + id isn't a snippet. + + 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.""" note = await notes_svc.get_note(user_id, snippet_id) if note is None or note.note_type != SNIPPET_NOTE_TYPE: return None cur = parse_snippet_fields(note.title, note.body, note.tags) overlay = { - "name": name, "code": code, "language": language, "signature": signature, - "when_to_use": when_to_use, "repo": repo, "path": path, "symbol": symbol, + "name": name, "code": code, "language": language, + "signature": signature, "when_to_use": when_to_use, } merged = {**cur, **{k: v for k, v in overlay.items() if v is not None}} + if locations is not None: + merged_locations = _normalize_locations(locations) + elif repo is not None or path is not None or symbol is not None: + base = cur["locations"][0] if cur["locations"] else {"repo": "", "path": "", "symbol": ""} + merged_locations = _normalize_locations([{ + "repo": repo if repo is not None else base["repo"], + "path": path if path is not None else base["path"], + "symbol": symbol if symbol is not None else base["symbol"], + }]) + else: + merged_locations = cur["locations"] + fields: dict = { "title": compose_title(merged["name"], merged["when_to_use"]), "body": compose_body( code=merged["code"], language=merged["language"], signature=merged["signature"], when_to_use=merged["when_to_use"], - repo=merged["repo"], path=merged["path"], symbol=merged["symbol"], + locations=merged_locations, ), } # Recompute tags: keep any non-language, non-marker tags the note already had diff --git a/tests/test_services_snippets.py b/tests/test_services_snippets.py index 3371484..839a096 100644 --- a/tests/test_services_snippets.py +++ b/tests/test_services_snippets.py @@ -62,6 +62,66 @@ def test_parse_falls_back_to_tag_for_language(): assert got["language"] == "ruby" +def test_compose_body_multi_location_renders_bullet_list(): + body = s.compose_body( + code="x = 1", + locations=[ + {"repo": "scribe", "path": "a.py", "symbol": "f"}, + {"repo": "web", "path": "b.ts", "symbol": "g"}, + ], + ) + assert "**Locations:**" in body + assert "- `scribe` · `a.py` · `f`" in body + assert "- `web` · `b.ts` · `g`" in body + assert "**Location:**" not in body.replace("**Locations:**", "") + + +def test_compose_body_single_location_via_list_uses_singular_label(): + body = s.compose_body( + code="x = 1", locations=[{"repo": "scribe", "path": "a.py", "symbol": "f"}], + ) + assert "**Location:** `scribe` · `a.py` · `f`" in body + assert "**Locations:**" not in body + + +def test_normalize_locations_drops_empty_and_dedups(): + got = s._normalize_locations([ + {"repo": "scribe", "path": "a.py", "symbol": "f"}, + {"repo": "", "path": "", "symbol": ""}, # dropped (empty) + {"repo": "scribe", "path": "a.py", "symbol": "f"}, # dropped (dup) + {"repo": "web", "path": "", "symbol": ""}, + ]) + assert got == [ + {"repo": "scribe", "path": "a.py", "symbol": "f"}, + {"repo": "web", "path": "", "symbol": ""}, + ] + + +def test_parse_round_trips_multi_location(): + body = s.compose_body( + code="const x = 1", language="ts", + locations=[ + {"repo": "scribe", "path": "a.ts", "symbol": "f"}, + {"repo": "web", "path": "b.ts", "symbol": "g"}, + ], + ) + got = s.parse_snippet_fields("n — u", body, ["ts", "snippet"]) + assert got["locations"] == [ + {"repo": "scribe", "path": "a.ts", "symbol": "f"}, + {"repo": "web", "path": "b.ts", "symbol": "g"}, + ] + # repo/path/symbol mirror the first location for back-compat. + assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.ts", "f") + + +def test_parse_legacy_single_location_line_still_works(): + # A body written by the pre-multi-location serializer. + body = "**Location:** `scribe` · `a.py` · `f`\n\n```py\nx = 1\n```\n" + got = s.parse_snippet_fields("n — u", body, None) + assert got["locations"] == [{"repo": "scribe", "path": "a.py", "symbol": "f"}] + assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.py", "f") + + def test_snippet_to_dict_includes_parsed_fields(): class FakeNote: title = "debounce — rate-limit" From 85625de39477b9ba9e13f9b486364ae0f397c494 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Jul 2026 17:11:47 -0400 Subject: [PATCH 05/14] =?UTF-8?q?feat(scribe):=20merge=5Fsnippets=20?= =?UTF-8?q?=E2=80=94=20unify=20found=20one-off=20snippets=20into=20one=20c?= =?UTF-8?q?anonical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the snippet-merge milestone (#231). The dedup gate only PREVENTS new near-duplicates; merge is the CURE for the ones already scattered. - services/snippets.py: merge_snippets(user_id, target_id, source_ids) — keep the target's scalar fields (name/when_to_use/signature/language/ code), union the sources' locations + extra tags onto it (so the survivor carries every call site as a location), trash the sources (recoverable), re-embed the survivor. Pure merge_snippet_fields() factored out for unit testing. Returns (survivor_note, merged_ids). - mcp/tools/snippets.py: merge_snippets(target_id, source_ids) tool (5th), and a create_snippet dedup-path nudge toward merge over a forced copy. - routes/snippets.py: POST /api/snippets//merge {source_ids} — share- aware (can_write target + every source, rule #78) with a same-owner guard (cross-owner merge is out of scope). - plugin reusing-code skill + MCP _INSTRUCTIONS: point found-duplicates at merge as the cure (rule #119 surfaces, not a Scribe rule). plugin.json 0.1.13 -> 0.1.14 in the same change (the #1040 marketplace-ship lesson). - Tests: pure merge-helper union/dedup; MCP tool (requires a source, survivor+merged_ids, not-found); route handler + 5-tool registration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t --- plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/reusing-code/SKILL.md | 10 ++++ src/scribe/mcp/server.py | 5 +- src/scribe/mcp/tools/snippets.py | 40 ++++++++++++++- src/scribe/routes/snippets.py | 40 +++++++++++++++ src/scribe/services/snippets.py | 75 +++++++++++++++++++++++++++++ tests/test_mcp_tool_snippets.py | 34 ++++++++++++- tests/test_routes_snippets.py | 2 +- tests/test_services_snippets.py | 16 ++++++ 9 files changed, 218 insertions(+), 6 deletions(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 2f925e7..7dc0459 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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.13", + "version": "0.1.14", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/skills/reusing-code/SKILL.md b/plugin/skills/reusing-code/SKILL.md index cf1cccd..dd904bf 100644 --- a/plugin/skills/reusing-code/SKILL.md +++ b/plugin/skills/reusing-code/SKILL.md @@ -37,6 +37,16 @@ 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). +## Found the same thing in several places — unify it + +When you notice the same reusable thing recorded (or written) as several +one-offs, don't leave the duplicates competing in recall — **merge them**. +`merge_snippets(canonical_id, [other_ids])` keeps one canonical record, folds in +the others' call sites as locations, and retires the duplicates to the trash. +The result is a single entry that shows every place the thing is used — which is +exactly the signal that it was worth consolidating. This is the cure the create +gate only hints at when it blocks a near-duplicate. + ## Why this pays off A one-off written a second time is the cost this avoids. Recording a snippet diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 5c1f8ca..3b92a5d 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -220,7 +220,10 @@ one-off; (2) the moment you build or notice something reusable, record it with create_snippet(name, code, when_to_use, language, signature, repo, path, symbol, project_id, system_ids) so a later session is offered it. Make when_to_use sharp — it becomes the title, which is what a recall menu shows. Edit an existing one -with update_snippet rather than recording a second copy. +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). 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 diff --git a/src/scribe/mcp/tools/snippets.py b/src/scribe/mcp/tools/snippets.py index 8f15541..528cc66 100644 --- a/src/scribe/mcp/tools/snippets.py +++ b/src/scribe/mcp/tools/snippets.py @@ -73,7 +73,11 @@ async def create_snippet( 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. + "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") @@ -157,6 +161,38 @@ async def update_snippet( return data +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): + for fn in (list_snippets, create_snippet, get_snippet, update_snippet, merge_snippets): mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/routes/snippets.py b/src/scribe/routes/snippets.py index 56d0aac..a52963f 100644 --- a/src/scribe/routes/snippets.py +++ b/src/scribe/routes/snippets.py @@ -120,6 +120,46 @@ async def update_snippet_route(snippet_id: int): return jsonify(snippets_svc.snippet_to_dict(updated)) +@snippets_bp.route("//merge", methods=["POST"]) +@login_required +async def merge_snippet_route(snippet_id: int): + """Unify source snippets into this one (the canonical target). Body: + {"source_ids": [int, ...]}. Requires write on the target and every source, + and all must share the target's owner (cross-owner merge is out of scope).""" + uid = get_current_user_id() + loaded = await _load_snippet(uid, snippet_id) + if loaded is None: + return not_found("Snippet") + target, _ = loaded + if not await can_write_note(uid, snippet_id): + return jsonify({"error": "Permission denied"}), 403 + + data = await request.get_json() or {} + raw = data.get("source_ids") or [] + source_ids = [s for s in raw if isinstance(s, int) and s != snippet_id] + if not source_ids: + return jsonify({"error": "source_ids (non-empty list of other snippet ids) is required"}), 400 + + owner_uid = target.user_id + for sid in source_ids: + sloaded = await _load_snippet(uid, sid) + if sloaded is None: + return not_found(f"Snippet {sid}") + snote, _ = sloaded + if snote.user_id != owner_uid: + return jsonify({"error": "can only merge snippets with the same owner"}), 400 + if not await can_write_note(uid, sid): + return jsonify({"error": f"Permission denied for snippet {sid}"}), 403 + + result = await snippets_svc.merge_snippets(owner_uid, snippet_id, source_ids) + if result is None: + return not_found("Snippet") + note, merged_ids = result + out = snippets_svc.snippet_to_dict(note) + out["merged_ids"] = merged_ids + return jsonify(out) + + @snippets_bp.route("/", methods=["DELETE"]) @login_required async def delete_snippet_route(snippet_id: int): diff --git a/src/scribe/services/snippets.py b/src/scribe/services/snippets.py index e1cdcac..1085927 100644 --- a/src/scribe/services/snippets.py +++ b/src/scribe/services/snippets.py @@ -382,3 +382,78 @@ async def update_snippet( # Title/body changed → refresh the embedding so recall reflects the edit. _embed_snippet(updated) return updated + + +# --- merge: unify found one-offs into one canonical snippet ------------------ + +def _extra_tags(tags: list[str] | None, language: str = "") -> list[str]: + """A snippet's caller tags — everything except the language + `snippet` + markers that compose_tags re-derives.""" + lang = (language or "").strip().lower() + return [t for t in (tags or []) if t and t != SNIPPET_TAG and t != lang] + + +def merge_snippet_fields( + target_fields: dict, target_tags: list[str] | None, sources: list[tuple[dict, list]] +) -> tuple[list[dict], list[str]]: + """Pure merge: union locations (target's first, then each source in order) + and union extra tags. The target's scalar fields (name/when_to_use/signature/ + language/code) win — only locations and tags accumulate. ``sources`` is a + list of (parsed_fields, tags). Returns (locations, extra_tags).""" + locations = list(target_fields.get("locations") or []) + extra = _extra_tags(target_tags, target_fields.get("language", "")) + for sfields, stags in sources: + locations.extend(sfields.get("locations") or []) + for t in _extra_tags(stags, sfields.get("language", "")): + if t not in extra: + extra.append(t) + return _normalize_locations(locations), extra + + +async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]): + """Unify source snippets INTO the target (all owned by user_id): union their + locations + tags onto the canonical target, keep the target's scalar fields, + trash the sources (recoverable), re-embed the survivor. + + Returns (merged_target_note, merged_source_ids), or None if the target isn't + the user's snippet. Source ids that aren't the user's snippets are skipped.""" + target = await notes_svc.get_note(user_id, target_id) + if target is None or target.note_type != SNIPPET_NOTE_TYPE: + return None + + sources = [] + for sid in source_ids: + if sid == target_id: + continue + s = await notes_svc.get_note(user_id, sid) + if s is not None and s.note_type == SNIPPET_NOTE_TYPE: + sources.append(s) + + tgt_fields = parse_snippet_fields(target.title, target.body, target.tags) + parsed_sources = [ + (parse_snippet_fields(s.title, s.body, s.tags), s.tags) for s in sources + ] + locations, extra_tags = merge_snippet_fields(tgt_fields, target.tags, parsed_sources) + + updated = await notes_svc.update_note( + user_id, target_id, + body=compose_body( + code=tgt_fields["code"], language=tgt_fields["language"], + signature=tgt_fields["signature"], when_to_use=tgt_fields["when_to_use"], + locations=locations, + ), + tags=compose_tags(tgt_fields["language"], extra_tags), + ) + if updated is None: + return None + + # Retire the merged-in sources to the trash (recoverable). + from scribe.services.trash import delete as trash_delete + merged_ids: list[int] = [] + for s in sources: + batch = await trash_delete(user_id, "note", s.id) + if batch is not None: + merged_ids.append(s.id) + + _embed_snippet(updated) + return updated, merged_ids diff --git a/tests/test_mcp_tool_snippets.py b/tests/test_mcp_tool_snippets.py index 7ea6284..fcc15ac 100644 --- a/tests/test_mcp_tool_snippets.py +++ b/tests/test_mcp_tool_snippets.py @@ -82,7 +82,38 @@ async def test_update_snippet_missing_raises(): await update_snippet(123, name="x") -def test_register_attaches_four_tools(): +@pytest.mark.asyncio +async def test_merge_snippets_requires_a_source(): + from scribe.mcp.tools.snippets import merge_snippets + with pytest.raises(ValueError): + await merge_snippets(target_id=1, source_ids=[]) + with pytest.raises(ValueError): + await merge_snippets(target_id=1, source_ids=[1]) # only self → nothing to merge + + +@pytest.mark.asyncio +async def test_merge_snippets_returns_survivor_and_merged_ids(): + survivor = _fake_snippet() + with patch("scribe.services.snippets.merge_snippets", + AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge: + from scribe.mcp.tools.snippets import merge_snippets + out = await merge_snippets(target_id=1, source_ids=[2, 3, 1]) + assert out["merged_ids"] == [2, 3] + assert out["snippet"]["name"] == "debounce" + # target_id passed through; self-id filtered out of the source list. + assert mock_merge.await_args.args[1] == 1 + assert mock_merge.await_args.args[2] == [2, 3] + + +@pytest.mark.asyncio +async def test_merge_snippets_not_found_raises(): + with patch("scribe.services.snippets.merge_snippets", AsyncMock(return_value=None)): + from scribe.mcp.tools.snippets import merge_snippets + with pytest.raises(ValueError): + await merge_snippets(target_id=1, source_ids=[2]) + + +def test_register_attaches_all_tools(): from scribe.mcp.tools import snippets names: list[str] = [] @@ -97,4 +128,5 @@ def test_register_attaches_four_tools(): snippets.register(FakeMcp()) assert set(names) == { "list_snippets", "create_snippet", "get_snippet", "update_snippet", + "merge_snippets", } diff --git a/tests/test_routes_snippets.py b/tests/test_routes_snippets.py index 79b0387..ca05cac 100644 --- a/tests/test_routes_snippets.py +++ b/tests/test_routes_snippets.py @@ -19,7 +19,7 @@ def test_snippet_handlers_callable(): from scribe.routes import snippets as routes for name in ( "list_snippets_route", "create_snippet_route", "get_snippet_route", - "update_snippet_route", "delete_snippet_route", + "update_snippet_route", "delete_snippet_route", "merge_snippet_route", ): assert callable(getattr(routes, name)) diff --git a/tests/test_services_snippets.py b/tests/test_services_snippets.py index 839a096..722510b 100644 --- a/tests/test_services_snippets.py +++ b/tests/test_services_snippets.py @@ -122,6 +122,22 @@ def test_parse_legacy_single_location_line_still_works(): assert (got["repo"], got["path"], got["symbol"]) == ("scribe", "a.py", "f") +def test_merge_snippet_fields_unions_locations_and_tags(): + target_fields = {"language": "py", "locations": [{"repo": "a", "path": "a.py", "symbol": "f"}]} + src1 = ({"language": "py", "locations": [{"repo": "b", "path": "b.py", "symbol": "g"}]}, + ["py", "snippet", "helper"]) + src2 = ({"language": "py", "locations": [{"repo": "a", "path": "a.py", "symbol": "f"}]}, # dup loc + ["snippet"]) + locs, extra = s.merge_snippet_fields(target_fields, ["py", "snippet", "core"], [src1, src2]) + # target location first, then unique source locations; dup dropped. + assert locs == [ + {"repo": "a", "path": "a.py", "symbol": "f"}, + {"repo": "b", "path": "b.py", "symbol": "g"}, + ] + # extra tags unioned, language + "snippet" markers excluded. + assert extra == ["core", "helper"] + + def test_snippet_to_dict_includes_parsed_fields(): class FakeNote: title = "debounce — rate-limit" From 7a81b7333e08334fd8c12c308c13f3bbbd273a44 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Jul 2026 17:22:01 -0400 Subject: [PATCH 06/14] =?UTF-8?q?feat(scribe):=20snippet=20merge=20UI=20?= =?UTF-8?q?=E2=80=94=20multi-select=20merge,=20repeatable=20locations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the snippet-merge milestone (#231): the human surfaces for merge + multi-location, at v1 quality. Frontend: - SnippetListView: a Select mode (checkbox on each card) → a sticky action bar → a merge modal that lets you pick which selected snippet is the canonical (the others fold in and go to trash). Accent border on selected cards, Moss action buttons (Hybrid rule). - SnippetEditorView: the single Location fieldset becomes a repeatable locations list (add/remove rows), so editing a merged snippet no longer collapses its call sites — no data loss. Sends `locations`. - SnippetDetailView: renders every location (Location vs Locations label). - api/snippets.ts: SnippetLocation type, `locations` on fields/input, mergeSnippets(). Backend (editor enablement): - create_snippet service + POST route accept an optional `locations` list; PATCH route forwards `locations` — so the editor's location list works uniformly on create and edit. Single repo/path/symbol remain the one-location shorthand (MCP create contract unchanged). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t --- frontend/src/api/snippets.ts | 25 +- frontend/src/views/SnippetDetailView.vue | 25 +- frontend/src/views/SnippetEditorView.vue | 110 ++++++--- frontend/src/views/SnippetListView.vue | 294 ++++++++++++++++++++++- src/scribe/routes/snippets.py | 3 + src/scribe/services/snippets.py | 5 +- 6 files changed, 414 insertions(+), 48 deletions(-) diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index ea5b5a0..40796f3 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -1,7 +1,16 @@ import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; +/** One canonical location of a reusable thing. A snippet that unifies several + * one-offs carries several — one per call site. */ +export interface SnippetLocation { + repo: string; + path: string; + symbol: string; +} + /** Structured fields parsed out of a snippet note (mirrors the backend - * `parse_snippet_fields` — see services/snippets.py). */ + * `parse_snippet_fields` — see services/snippets.py). `repo`/`path`/`symbol` + * mirror the first location for back-compat; `locations` is the full list. */ export interface SnippetFields { name: string; when_to_use: string; @@ -10,6 +19,7 @@ export interface SnippetFields { repo: string; path: string; symbol: string; + locations: SnippetLocation[]; code: string; } @@ -50,9 +60,7 @@ export interface SnippetInput { language?: string; signature?: string; when_to_use?: string; - repo?: string; - path?: string; - symbol?: string; + locations?: SnippetLocation[]; tags?: string[]; project_id?: number | null; } @@ -85,3 +93,12 @@ export async function updateSnippet( export async function deleteSnippet(id: number): Promise { return apiDelete(`/api/snippets/${id}`); } + +/** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged + * survivor plus `merged_ids` — the sources actually folded in and trashed. */ +export async function mergeSnippets( + targetId: number, + sourceIds: number[], +): Promise { + return apiPost(`/api/snippets/${targetId}/merge`, { source_ids: sourceIds }); +} diff --git a/frontend/src/views/SnippetDetailView.vue b/frontend/src/views/SnippetDetailView.vue index 9dca0dd..63ca1f2 100644 --- a/frontend/src/views/SnippetDetailView.vue +++ b/frontend/src/views/SnippetDetailView.vue @@ -21,11 +21,11 @@ const canWrite = computed(() => { return p === undefined || p === "owner" || p === "edit" || p === "admin"; }); -const locationParts = computed(() => { - const s = snippet.value?.snippet; - if (!s) return []; - return [s.repo, s.path, s.symbol].filter((p) => p && p.trim()); -}); +const locations = computed(() => snippet.value?.snippet.locations ?? []); + +function locParts(loc: { repo: string; path: string; symbol: string }): string[] { + return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim()); +} async function load() { loading.value = true; @@ -92,10 +92,12 @@ async function confirmDelete() {
Signature
{{ snippet.snippet.signature }}
-