Drafter reuse-recall (both halves) + the multi-user ACL work it exposed #78
@@ -36,6 +36,7 @@ export interface Snippet {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
snippet: SnippetFields;
|
snippet: SnippetFields;
|
||||||
|
systems?: { id: number; name: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
|
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
|
||||||
@@ -63,14 +64,18 @@ export interface SnippetInput {
|
|||||||
locations?: SnippetLocation[];
|
locations?: SnippetLocation[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
|
system_ids?: number[];
|
||||||
|
/** Create only: record it even though a near-duplicate already exists. */
|
||||||
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSnippets(
|
export async function listSnippets(
|
||||||
params: { q?: string; tag?: string } = {},
|
params: { q?: string; tag?: string; projectId?: number | null } = {},
|
||||||
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.q) qs.set("q", params.q);
|
if (params.q) qs.set("q", params.q);
|
||||||
if (params.tag) qs.set("tag", params.tag);
|
if (params.tag) qs.set("tag", params.tag);
|
||||||
|
if (params.projectId) qs.set("project_id", String(params.projectId));
|
||||||
const query = qs.toString();
|
const query = qs.toString();
|
||||||
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
getSnippet,
|
getSnippet,
|
||||||
@@ -8,11 +8,15 @@ import {
|
|||||||
type SnippetInput,
|
type SnippetInput,
|
||||||
type SnippetLocation,
|
type SnippetLocation,
|
||||||
} from "@/api/snippets";
|
} from "@/api/snippets";
|
||||||
|
import { ApiError } from "@/api/client";
|
||||||
|
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||||
|
import { useSystemsStore } from "@/stores/systems";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
const systemsStore = useSystemsStore();
|
||||||
|
|
||||||
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
|
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
|
||||||
const isEditing = computed(() => editId.value !== null);
|
const isEditing = computed(() => editId.value !== null);
|
||||||
@@ -47,17 +51,66 @@ function removeLocation(i: number) {
|
|||||||
locations.value.splice(i, 1);
|
locations.value.splice(i, 1);
|
||||||
}
|
}
|
||||||
const tagsText = ref("");
|
const tagsText = ref("");
|
||||||
|
const projectId = ref<number | null>(null);
|
||||||
|
const systemIds = ref<number[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const loadError = ref<string | null>(null);
|
const loadError = ref<string | null>(null);
|
||||||
const nameRef = ref<HTMLInputElement | 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(
|
const canSave = computed(
|
||||||
() => !!form.value.name.trim() && !!form.value.code.trim() && !saving.value,
|
() => !!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() {
|
async function load() {
|
||||||
if (!isEditing.value) {
|
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();
|
await nextTick();
|
||||||
nameRef.value?.focus();
|
nameRef.value?.focus();
|
||||||
return;
|
return;
|
||||||
@@ -82,6 +135,9 @@ async function load() {
|
|||||||
tagsText.value = s.tags
|
tagsText.value = s.tags
|
||||||
.filter((t) => t !== "snippet" && t !== f.language)
|
.filter((t) => t !== "snippet" && t !== f.language)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
projectId.value = s.project_id ?? null;
|
||||||
|
systemIds.value = (s.systems ?? []).map((sys) => sys.id);
|
||||||
|
await loadSystems();
|
||||||
} catch {
|
} catch {
|
||||||
loadError.value = "Couldn't load this snippet to edit.";
|
loadError.value = "Couldn't load this snippet to edit.";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -115,20 +171,37 @@ async function save() {
|
|||||||
when_to_use: form.value.when_to_use.trim(),
|
when_to_use: form.value.when_to_use.trim(),
|
||||||
locations: cleanLocations(),
|
locations: cleanLocations(),
|
||||||
tags: parseTags(),
|
tags: parseTags(),
|
||||||
|
project_id: projectId.value,
|
||||||
|
system_ids: systemIds.value,
|
||||||
};
|
};
|
||||||
|
if (forceCreate.value) payload.force = true;
|
||||||
try {
|
try {
|
||||||
const result = isEditing.value
|
const result = isEditing.value
|
||||||
? await updateSnippet(editId.value!, payload)
|
? await updateSnippet(editId.value!, payload)
|
||||||
: await createSnippet(payload);
|
: await createSnippet(payload);
|
||||||
toast.show(isEditing.value ? "Snippet saved" : "Snippet created");
|
toast.show(isEditing.value ? "Snippet saved" : "Snippet created");
|
||||||
router.push(`/snippets/${result.id}`);
|
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");
|
toast.show("Failed to save snippet", "error");
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveAnyway() {
|
||||||
|
duplicate.value = null;
|
||||||
|
forceCreate.value = true;
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
if (isEditing.value) router.push(`/snippets/${editId.value}`);
|
if (isEditing.value) router.push(`/snippets/${editId.value}`);
|
||||||
else router.push("/snippets");
|
else router.push("/snippets");
|
||||||
@@ -243,6 +316,41 @@ function cancel() {
|
|||||||
<p class="hint">Extra tags. Language and “snippet” are added automatically.</p>
|
<p class="hint">Extra tags. Language and “snippet” are added automatically.</p>
|
||||||
</div>
|
</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">
|
<div class="actions">
|
||||||
<button type="button" class="btn-secondary" @click="cancel">Cancel</button>
|
<button type="button" class="btn-secondary" @click="cancel">Cancel</button>
|
||||||
<button type="submit" class="btn-primary" :disabled="!canSave">
|
<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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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.",
|
"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" },
|
"author": { "name": "Bryan Van Deusen" },
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"scribe": {
|
"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?
|
- About to write a utility, hook, formatter, adapter, or a reusable component?
|
||||||
**Search snippets before writing it.** `list_snippets(q="…")` (or a plain
|
**Search snippets before writing it.** `list_snippets(q="…")` (or a plain
|
||||||
`search`) — a matching one may already exist, in this project or another.
|
`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
|
- 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.
|
`location` points at the reference implementation. Adapt, don't re-derive.
|
||||||
- If auto-inject already surfaced a snippet title that looks relevant, that's
|
- 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
|
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).
|
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
|
## Found the same thing in several places — unify it
|
||||||
|
|
||||||
When you notice the same reusable thing recorded (or written) as several
|
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
|
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
|
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
|
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
|
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
|
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
|
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).
|
"""List recorded snippets (reusable functions/components).
|
||||||
|
|
||||||
Args:
|
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).
|
tag: Filter to a single tag, e.g. a language like "python" (optional).
|
||||||
limit: Max results (1-100).
|
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
|
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).
|
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()
|
uid = current_user_id()
|
||||||
items, total = await snippets_svc.list_snippets(
|
items, total = await snippets_svc.list_snippets(
|
||||||
uid, q=q or None, tag=tag, limit=max(1, min(limit, 100)),
|
uid, q=q or None, tag=tag, limit=max(1, min(limit, 100)),
|
||||||
|
project_id=project_id or None,
|
||||||
)
|
)
|
||||||
return {"snippets": items, "total": total}
|
return {"snippets": items, "total": total}
|
||||||
|
|
||||||
@@ -43,6 +50,7 @@ async def create_snippet(
|
|||||||
repo: str = "",
|
repo: str = "",
|
||||||
path: str = "",
|
path: str = "",
|
||||||
symbol: str = "",
|
symbol: str = "",
|
||||||
|
locations: list[dict] | None = None,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
system_ids: list[int] | None = None,
|
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
|
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.
|
title, so it's what a recall menu shows. Keep it sharp.
|
||||||
repo/path/symbol: Canonical location of the reference implementation.
|
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).
|
tags: Extra plain-string tags (language + "snippet" are added for you).
|
||||||
project_id: Associate with a project (0 = no project). Snippets surface
|
project_id: Associate with a project (0 = no project). Snippets surface
|
||||||
proactively within their project; search finds them across projects.
|
proactively within their project; search finds them across projects.
|
||||||
@@ -87,6 +98,7 @@ async def create_snippet(
|
|||||||
body = snippets_svc.compose_body(
|
body = snippets_svc.compose_body(
|
||||||
code=code, language=language, signature=signature,
|
code=code, language=language, signature=signature,
|
||||||
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
|
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
|
||||||
|
locations=locations,
|
||||||
)
|
)
|
||||||
if not force:
|
if not force:
|
||||||
dup = await dedup_svc.find_duplicate_note(
|
dup = await dedup_svc.find_duplicate_note(
|
||||||
@@ -99,7 +111,7 @@ async def create_snippet(
|
|||||||
note = await snippets_svc.create_snippet(
|
note = await snippets_svc.create_snippet(
|
||||||
uid, name=name, code=code, language=language, signature=signature,
|
uid, name=name, code=code, language=language, signature=signature,
|
||||||
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
|
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:
|
if system_ids:
|
||||||
await systems_svc.set_record_systems(uid, note.id, 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(
|
async def update_snippet(
|
||||||
snippet_id: int,
|
snippet_id: int,
|
||||||
name: str = "",
|
name: str | None = None,
|
||||||
code: str = "",
|
code: str | None = None,
|
||||||
language: str = "",
|
language: str | None = None,
|
||||||
signature: str = "",
|
signature: str | None = None,
|
||||||
when_to_use: str = "",
|
when_to_use: str | None = None,
|
||||||
repo: str = "",
|
repo: str | None = None,
|
||||||
path: str = "",
|
path: str | None = None,
|
||||||
symbol: str = "",
|
symbol: str | None = None,
|
||||||
|
locations: list[dict] | None = None,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
system_ids: list[int] | None = None,
|
system_ids: list[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a snippet. Only provided fields change — empty strings leave that
|
"""Update a snippet. Only the fields you pass change.
|
||||||
field unchanged; pass tags to replace the extra-tag set."""
|
|
||||||
uid = current_user_id()
|
|
||||||
|
|
||||||
def _n(v: str) -> str | None:
|
An omitted field is left alone; an EMPTY STRING clears it — so a stale
|
||||||
return v if v != "" else None
|
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(
|
note = await snippets_svc.update_snippet(
|
||||||
uid, snippet_id,
|
uid, snippet_id,
|
||||||
name=_n(name), code=_n(code), language=_n(language),
|
name=name, code=code, language=language,
|
||||||
signature=_n(signature), when_to_use=_n(when_to_use),
|
signature=signature, when_to_use=when_to_use,
|
||||||
repo=_n(repo), path=_n(path), symbol=_n(symbol),
|
repo=repo, path=path, symbol=symbol,
|
||||||
tags=tags, project_id=project_id or None,
|
locations=locations, tags=tags, project_id=project,
|
||||||
)
|
)
|
||||||
if note is None:
|
if note is None:
|
||||||
raise ValueError(f"snippet {snippet_id} not found")
|
raise ValueError(f"snippet {snippet_id} not found")
|
||||||
@@ -161,6 +190,20 @@ async def update_snippet(
|
|||||||
return data
|
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:
|
async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
|
||||||
"""Unify duplicate/variant snippets INTO one canonical record — the cure for
|
"""Unify duplicate/variant snippets INTO one canonical record — the cure for
|
||||||
the same reusable thing recorded as several one-offs.
|
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:
|
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)
|
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.auth import get_current_user_id, login_required
|
||||||
from scribe.routes.utils import not_found, parse_pagination
|
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 snippets as snippets_svc
|
||||||
|
from scribe.services import systems as systems_svc
|
||||||
from scribe.services.access import can_write_note
|
from scribe.services.access import can_write_note
|
||||||
from scribe.services.notes import get_note_for_user
|
from scribe.services.notes import get_note_for_user
|
||||||
|
|
||||||
@@ -46,9 +48,13 @@ async def list_snippets_route():
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
q = request.args.get("q") or None
|
q = request.args.get("q") or None
|
||||||
tag = request.args.get("tag", "")
|
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()
|
limit, offset = parse_pagination()
|
||||||
items, total = await snippets_svc.list_snippets(
|
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})
|
return jsonify({"snippets": items, "total": total})
|
||||||
|
|
||||||
@@ -63,6 +69,31 @@ async def create_snippet_route():
|
|||||||
if not name or not code:
|
if not name or not code:
|
||||||
return jsonify({"error": "name and code are required"}), 400
|
return jsonify({"error": "name and code are required"}), 400
|
||||||
project_id = data.get("project_id") or None
|
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(
|
note = await snippets_svc.create_snippet(
|
||||||
uid,
|
uid,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -77,7 +108,13 @@ async def create_snippet_route():
|
|||||||
tags=data.get("tags"),
|
tags=data.get("tags"),
|
||||||
project_id=project_id,
|
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"])
|
@snippets_bp.route("/<int:snippet_id>", methods=["GET"])
|
||||||
@@ -90,6 +127,13 @@ async def get_snippet_route(snippet_id: int):
|
|||||||
note, permission = loaded
|
note, permission = loaded
|
||||||
data = snippets_svc.snippet_to_dict(note)
|
data = snippets_svc.snippet_to_dict(note)
|
||||||
data["permission"] = permission
|
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)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,12 +159,20 @@ async def update_snippet_route(snippet_id: int):
|
|||||||
if "tags" in data:
|
if "tags" in data:
|
||||||
kwargs["tags"] = data["tags"]
|
kwargs["tags"] = data["tags"]
|
||||||
if "project_id" in data:
|
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
|
kwargs["project_id"] = data["project_id"] or None
|
||||||
|
|
||||||
updated = await snippets_svc.update_snippet(owner_uid, snippet_id, **kwargs)
|
updated = await snippets_svc.update_snippet(owner_uid, snippet_id, **kwargs)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
return not_found("Snippet")
|
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"])
|
@snippets_bp.route("/<int:snippet_id>/merge", methods=["POST"])
|
||||||
@@ -173,8 +225,6 @@ async def delete_snippet_route(snippet_id: int):
|
|||||||
note, _ = loaded
|
note, _ = loaded
|
||||||
if not await can_write_note(uid, snippet_id):
|
if not await can_write_note(uid, snippet_id):
|
||||||
return jsonify({"error": "Permission denied"}), 403
|
return jsonify({"error": "Permission denied"}), 403
|
||||||
from scribe.services.trash import delete as trash_delete
|
if not await snippets_svc.delete_snippet(note.user_id, snippet_id):
|
||||||
batch = await trash_delete(note.user_id, "note", snippet_id)
|
|
||||||
if batch is None:
|
|
||||||
return not_found("Snippet")
|
return not_found("Snippet")
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|||||||
@@ -59,21 +59,27 @@ async def query_knowledge(
|
|||||||
q: str | None,
|
q: str | None,
|
||||||
limit: int,
|
limit: int,
|
||||||
offset: int,
|
offset: int,
|
||||||
|
project_id: int | None = None,
|
||||||
) -> tuple[list[dict], int]:
|
) -> tuple[list[dict], int]:
|
||||||
"""Query knowledge objects (non-task notes) with filters.
|
"""Query knowledge objects (non-task notes) with filters.
|
||||||
|
|
||||||
|
`project_id` narrows to one project (None = every project).
|
||||||
|
|
||||||
Returns (items, total_count).
|
Returns (items, total_count).
|
||||||
"""
|
"""
|
||||||
# Semantic search path — scores take priority over sort
|
# Semantic search path — scores take priority over sort
|
||||||
if q:
|
if q:
|
||||||
return await _semantic_knowledge_search(
|
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:
|
async with async_session() as session:
|
||||||
base = select(Note).where(Note.user_id == user_id)
|
base = select(Note).where(Note.user_id == user_id)
|
||||||
|
|
||||||
base = _apply_type_filter(base, note_type)
|
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:
|
for tag in tags:
|
||||||
base = base.where(Note.tags.contains([tag]))
|
base = base.where(Note.tags.contains([tag]))
|
||||||
@@ -104,6 +110,7 @@ async def _semantic_knowledge_search(
|
|||||||
tags: list[str],
|
tags: list[str],
|
||||||
limit: int,
|
limit: int,
|
||||||
offset: int,
|
offset: int,
|
||||||
|
project_id: int | None = None,
|
||||||
) -> tuple[list[dict], int]:
|
) -> tuple[list[dict], int]:
|
||||||
"""Hybrid search: keyword matches first (title/body ILIKE), then semantic results.
|
"""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))
|
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
|
||||||
)
|
)
|
||||||
base = _apply_type_filter(base, note_type)
|
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:
|
for tag in tags:
|
||||||
base = base.where(Note.tags.contains([tag]))
|
base = base.where(Note.tags.contains([tag]))
|
||||||
# Title matches first, then body-only matches, newest first within each
|
# 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),
|
limit=min(200, limit * 4),
|
||||||
threshold=0.3,
|
threshold=0.3,
|
||||||
is_task=is_task_filter,
|
is_task=is_task_filter,
|
||||||
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
for _score, note in candidates:
|
for _score, note in candidates:
|
||||||
if note.deleted_at is not None:
|
if note.deleted_at is not None:
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ from scribe.services import notes as notes_svc
|
|||||||
SNIPPET_NOTE_TYPE = "snippet"
|
SNIPPET_NOTE_TYPE = "snippet"
|
||||||
SNIPPET_TAG = "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:
|
def _embed_snippet(note) -> None:
|
||||||
"""Fire-and-forget embedding refresh for a snippet.
|
"""Fire-and-forget embedding refresh for a snippet.
|
||||||
@@ -235,11 +240,13 @@ def parse_snippet_fields(
|
|||||||
fields["language"] = m.group(1).strip()
|
fields["language"] = m.group(1).strip()
|
||||||
fields["code"] = m.group(2)
|
fields["code"] = m.group(2)
|
||||||
|
|
||||||
if not fields["language"]:
|
# Language fallback for a body whose code fence lost its language. Only the
|
||||||
for t in tags or []:
|
# FIRST tag can be trusted: compose_tags emits [language, "snippet", *caller],
|
||||||
if t and t != SNIPPET_TAG:
|
# so a leading tag that isn't the marker is the language — while a leading
|
||||||
fields["language"] = t
|
# marker means no language was recorded. Scanning for "first tag that isn't
|
||||||
break
|
# 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
|
return fields
|
||||||
|
|
||||||
|
|
||||||
@@ -302,8 +309,13 @@ async def list_snippets(
|
|||||||
tag: str = "",
|
tag: str = "",
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
project_id: int | None = None,
|
||||||
) -> tuple[list[dict], int]:
|
) -> 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(
|
return await knowledge_svc.query_knowledge(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
note_type=SNIPPET_NOTE_TYPE,
|
note_type=SNIPPET_NOTE_TYPE,
|
||||||
@@ -312,6 +324,7 @@ async def list_snippets(
|
|||||||
q=q,
|
q=q,
|
||||||
limit=max(1, min(limit, 100)),
|
limit=max(1, min(limit, 100)),
|
||||||
offset=max(0, offset),
|
offset=max(0, offset),
|
||||||
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -329,12 +342,15 @@ async def update_snippet(
|
|||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
locations: list[dict] | None = None,
|
locations: list[dict] | None = None,
|
||||||
tags: list[str] | 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
|
"""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
|
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.
|
||||||
|
|
||||||
|
``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
|
Locations: ``locations`` replaces the whole set; else a legacy single
|
||||||
``repo``/``path``/``symbol`` overlays onto the first existing location; else
|
``repo``/``path``/``symbol`` overlays onto the first existing location; else
|
||||||
the existing locations are kept."""
|
the existing locations are kept."""
|
||||||
@@ -377,7 +393,7 @@ async def update_snippet(
|
|||||||
fields["tags"] = compose_tags(
|
fields["tags"] = compose_tags(
|
||||||
merged["language"], tags if tags is not None else existing_extra
|
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
|
fields["project_id"] = project_id
|
||||||
|
|
||||||
updated = await notes_svc.update_note(user_id, snippet_id, **fields)
|
updated = await notes_svc.update_note(user_id, snippet_id, **fields)
|
||||||
@@ -387,6 +403,21 @@ async def update_snippet(
|
|||||||
return updated
|
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 ------------------
|
# --- merge: unify found one-offs into one canonical snippet ------------------
|
||||||
|
|
||||||
def _extra_tags(tags: list[str] | None, language: str = "") -> list[str]:
|
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")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_merge_snippets_requires_a_source():
|
async def test_merge_snippets_requires_a_source():
|
||||||
from scribe.mcp.tools.snippets import merge_snippets
|
from scribe.mcp.tools.snippets import merge_snippets
|
||||||
@@ -128,5 +195,5 @@ def test_register_attaches_all_tools():
|
|||||||
snippets.register(FakeMcp())
|
snippets.register(FakeMcp())
|
||||||
assert set(names) == {
|
assert set(names) == {
|
||||||
"list_snippets", "create_snippet", "get_snippet", "update_snippet",
|
"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():
|
def test_service_functions_take_user_id():
|
||||||
"""Routes must call snippet services with user_id — verify the contract."""
|
"""Routes must call snippet services with user_id — verify the contract."""
|
||||||
from scribe.services import snippets as svc
|
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)
|
fn = getattr(svc, fn_name)
|
||||||
assert callable(fn)
|
assert callable(fn)
|
||||||
assert "user_id" in inspect.signature(fn).parameters
|
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():
|
def test_update_field_map_matches_service_kwargs():
|
||||||
"""Every field the PATCH route forwards must be a real update_snippet kwarg
|
"""Every field the PATCH route forwards must be a real update_snippet kwarg
|
||||||
(rule #33 interface-contract parity)."""
|
(rule #33 interface-contract parity)."""
|
||||||
|
|||||||
@@ -62,6 +62,26 @@ def test_parse_falls_back_to_tag_for_language():
|
|||||||
assert got["language"] == "ruby"
|
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():
|
def test_compose_body_multi_location_renders_bullet_list():
|
||||||
body = s.compose_body(
|
body = s.compose_body(
|
||||||
code="x = 1",
|
code="x = 1",
|
||||||
|
|||||||
Reference in New Issue
Block a user