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