fix(snippets): close the recall-surface gaps found reviewing the Drafter
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
Four defects from the 2026-07-25 review of the recall (#227) and merge (#231) milestones. The theme: a snippet could be recorded but not fully corrected, and the agent and web surfaces had drifted apart. - #2076 language was mis-derived from the first caller tag, so a snippet created with tags and no language read that tag back as its language — corrupting the tag set and the code fence on the next update. Only the FIRST tag can carry the language, since compose_tags emits [language, "snippet", *caller]. - #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be cleared and no snippet detached from its project. Now an omitted field is left alone, an empty string clears, and project_id follows the -1 = detach convention. A service-level UNSET sentinel keeps None available as the clear. - #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet could not be retired by the agent that recorded it), locations on MCP create and update, system_ids through the REST routes and the editor, and the near-duplicate gate on REST create with a "record it anyway" escape. - #2079 project scoping: list_snippets takes project_id through the service, the MCP tool and the REST route, defaulting to every project — reaching across projects is the point when the helper you need was written elsewhere. Sharing the list across owners is deliberately NOT in here: query_knowledge is shared with the Knowledge browse surface, so widening it changes behaviour well beyond snippets. Left open on #2079 for a scope decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
@@ -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