Files
FabledGrimoire/frontend/src/api/snippets.ts
T
bvandeusenandClaude Opus 5.5 4ae18a9dd9
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 34s
feat(snippets): a snippet has notes; when_to_use is the situation it is ranked on (#4378)
A snippet had no field for prose, so what a session learned about one went
into when_to_use — the trigger joined onto every chunk it is embedded as. A
sweep found write-ups of up to 3 KB there, headings and all.

- notes: stored after the code under `## Notes`, parsed back from the body,
  carried by every path that rebuilds it (update, merge, un-merge). A
  snippet with no notes composes the body it always did.
- create/update_snippet (MCP) take notes and return trigger_advice when
  when_to_use is long, headed or multi-paragraph. Advice, not a refusal.
- Tool docs, the reusing-code skill and the editor hint describe the trigger
  as the situation and point the explanation at notes.
- Editor gains a Notes field; the detail view renders it as markdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:27:22 -04:00

218 lines
8.2 KiB
TypeScript

import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** One canonical location of a reusable thing. A snippet that unifies several
* one-offs carries several — one per call site. */
export interface SnippetLocation {
repo: string;
path: string;
symbol: string;
}
/** Structured fields parsed out of a snippet note (mirrors the backend
* `parse_snippet_fields` — see services/snippets.py). `repo`/`path`/`symbol`
* mirror the first location for back-compat; `locations` is the full list. */
export interface SnippetFields {
name: string;
when_to_use: string;
signature: string;
language: string;
repo: string;
path: string;
symbol: string;
locations: SnippetLocation[];
/** The snippets folded into this one by merge, oldest first. Read-only: merge
* is the only thing that adds to it, and an edit carries it forward.
*
* Each entry records what THAT source contributed — never what the survivor
* already had — which is what lets un-merge subtract exactly. An entry with
* no `locations`/`tags` predates that attribution and cannot be un-merged. */
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
code: string;
/** Free text that is not the situation — why, history, caveats (#4378).
* Kept out of `when_to_use`, which the snippet is ranked on. */
notes: string;
}
/** A full snippet record: the note dict plus the parsed `snippet` sub-object,
* as returned by the backend `snippet_to_dict`. */
export interface Snippet {
id: number;
title: string;
body: string;
tags: string[];
note_type: string;
project_id: number | null;
permission?: string;
created_at: string;
updated_at: string;
snippet: SnippetFields;
systems?: { id: number; name: string }[];
/** Set when another user owns this record; `owner` is their username and
* `permission` your access level on it. */
shared?: boolean;
owner?: string | null;
}
/** Kept as a name because every consumer here says "snippet usage" — but it IS
* the shared shape, since rules answer the same question off their own table
* (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
* fields here is how the two drift. */
export type SnippetUsage = RecordUsage;
/** Result of the last drift check — does the recorded location and code still
* match source? The check runs agent-side (Scribe has no checkout); this is the
* remembered verdict. `current` is false once the snippet has been edited since
* the check, at which point the verdict describes code that's no longer there. */
export interface SnippetVerification {
status: "ok" | "missing" | "moved" | "changed" | "unverified";
current: boolean;
checked_at: string | null;
detail?: string | null;
path?: string | null;
needs_attention?: boolean;
}
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
* field here is a truncated *body preview* (the knowledge feed's naming), not
* the parsed fields above. */
export interface SnippetListItem {
id: number;
title: string;
tags: string[];
note_type: string;
snippet: string;
created_at: string;
updated_at: string;
/** Present only when the record belongs to someone else — a suggestion from
* them, not one of your own. Absent means it's yours. */
shared?: boolean;
owner?: string | null;
/** The recorded language, when one was given. Projected from the `data`
* mirror so lists can show it without parsing the body — and so a prior-art
* hit can be flagged as being in a DIFFERENT language than the file being
* written, which is a shape to adapt rather than code to paste. */
language?: string;
/** When to reach for it, projected from the `data` mirror. The title is the
* name alone since milestone 427, so this is where the situation lives. */
when_to_use?: string;
/** Always present from the backend, zero-filled for records with no events. */
usage?: SnippetUsage;
/** Present on the detail record; the list feed carries it when a check has
* been recorded. */
verification?: SnippetVerification;
}
/** Create/update payload — discrete fields the backend serializes into the
* note (title/body/tags). Empty strings are applied; omitted keys are left
* unchanged on update. */
export interface SnippetInput {
name: string;
code: string;
language?: string;
signature?: string;
when_to_use?: string;
notes?: string;
locations?: SnippetLocation[];
tags?: string[];
project_id?: number | null;
system_ids?: number[];
/** Create only: record it even though a near-duplicate already exists. */
force?: boolean;
}
/** `repo`/`path`/`symbol` are the reverse lookup — "what already lives here?"
* They must all match the same recorded location; `path` also matches anything
* beneath it. Combinable with `q`. */
export async function listSnippets(
params: {
q?: string;
tag?: string;
projectId?: number | null;
repo?: string;
path?: string;
symbol?: string;
/** Drift check: "attention" | "ok" | "unverified" | "drifted" | a status. */
verification?: string;
} = {},
): Promise<{ snippets: SnippetListItem[]; total: number }> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.tag) qs.set("tag", params.tag);
if (params.projectId) qs.set("project_id", String(params.projectId));
if (params.repo) qs.set("repo", params.repo);
if (params.path) qs.set("path", params.path);
if (params.symbol) qs.set("symbol", params.symbol);
if (params.verification) qs.set("verification", params.verification);
const query = qs.toString();
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
}
export async function getSnippet(id: number): Promise<Snippet> {
return apiGet(`/api/snippets/${id}`);
}
export async function createSnippet(data: SnippetInput): Promise<Snippet> {
return apiPost("/api/snippets", data);
}
export async function updateSnippet(
id: number,
data: Partial<SnippetInput>,
): Promise<Snippet> {
return apiPatch(`/api/snippets/${id}`, data);
}
export async function deleteSnippet(id: number): Promise<void> {
return apiDelete(`/api/snippets/${id}`);
}
/** A set of snippets that resemble each other closely enough to be worth
* merging. Grouping is transitive, so a set can hold members that don't
* directly resemble each other — read it as a proposal, not a verdict. */
export interface DuplicateGroup {
note_ids: number[];
snippets: { id: number; title: string }[];
/** The strongest resemblance within the set — how confident the suggestion is. */
top_score: number;
}
/** Near-duplicates already in the record. The create gate prevents new ones and
* merge cures the ones you point it at; this is what finds them. */
export async function findDuplicateSnippets(
threshold?: number,
): Promise<{ groups: DuplicateGroup[]; threshold: number }> {
const qs = threshold ? `?threshold=${threshold}` : "";
return apiGet(`/api/snippets/duplicates${qs}`);
}
/** Record a drift-check verdict. The check itself runs where the code is — an
* agent with the working tree — since Scribe has no checkout. This stores what
* was found, and is how the UI clears a stale marker after a manual fix. */
export async function verifySnippet(
id: number,
verdict: { status: string; detail?: string; path?: string },
): Promise<Snippet> {
return apiPost(`/api/snippets/${id}/verify`, verdict);
}
/** Pull one source back out of a merged survivor: restores it and strips exactly
* what it contributed. Also repairs a half-undone merge — a source restored
* from the trash by hand leaves the survivor still claiming its call sites. */
export async function unmergeSnippet(
survivorId: number,
sourceId: number,
): Promise<{ survivor: Snippet; restored: Snippet | null }> {
return apiPost(`/api/snippets/${survivorId}/unmerge`, { source_id: sourceId });
}
/** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged
* survivor plus `merged_ids` — the sources actually folded in and trashed. */
export async function mergeSnippets(
targetId: number,
sourceIds: number[],
): Promise<Snippet & { merged_ids: number[] }> {
return apiPost(`/api/snippets/${targetId}/merge`, { source_ids: sourceIds });
}