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