Files
FabledScribe/frontend/src/api/snippets.ts
T
bvandeusen b33e2a79c6
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
fix(snippets): close the recall-surface gaps found reviewing the Drafter
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
2026-07-25 19:00:05 -04:00

110 lines
3.3 KiB
TypeScript

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[];
code: string;
}
/** A full snippet record: the note dict plus the parsed `snippet` sub-object,
* as returned by the backend `snippet_to_dict`. */
export interface Snippet {
id: number;
title: string;
body: string;
tags: string[];
note_type: string;
project_id: number | null;
permission?: string;
created_at: string;
updated_at: string;
snippet: SnippetFields;
systems?: { id: number; name: string }[];
}
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
* field here is a truncated *body preview* (the knowledge feed's naming), not
* the parsed fields above. */
export interface SnippetListItem {
id: number;
title: string;
tags: string[];
note_type: string;
snippet: string;
created_at: string;
updated_at: string;
}
/** Create/update payload — discrete fields the backend serializes into the
* note (title/body/tags). Empty strings are applied; omitted keys are left
* unchanged on update. */
export interface SnippetInput {
name: string;
code: string;
language?: string;
signature?: string;
when_to_use?: string;
locations?: SnippetLocation[];
tags?: string[];
project_id?: number | null;
system_ids?: number[];
/** Create only: record it even though a near-duplicate already exists. */
force?: boolean;
}
export async function listSnippets(
params: { q?: string; tag?: string; projectId?: number | null } = {},
): Promise<{ snippets: SnippetListItem[]; total: number }> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.tag) qs.set("tag", params.tag);
if (params.projectId) qs.set("project_id", String(params.projectId));
const query = qs.toString();
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
}
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}`);
}
/** 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 });
}