CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Has been skipped
Closes #2244. Retrieval matches on concept, and concepts are language-agnostic: asking about a TypeScript union-find scores 0.72-0.73 against a PYTHON snippet, comfortably over the 0.68 bar. That is useful — a different-language solution gives you the shape even when the code isn't reusable — but the menu line said nothing about it, so the reader either dismissed a good structural reference or pasted Python into a .ts file. Worth noting this predates the concept-query change: raw TS code already matched the Python snippet at 0.73, because the embedder reads identifiers and structure semantically rather than syntactically. The fail state has been shipping quietly; #2242 only made it an intended use rather than an accident. - knowledge._note_to_item projects `language` from the data mirror, same shape as the existing verification projection — a plain column read, no body parsing. - The semantic arm carries language through on the item it builds; it is the arm where these arise, since a snippet recorded AT the path you're editing is almost never in another language. - _prior_art_line folds it into the marker: [similar 0.72 · python]. Together with the score rather than after the title, because the two jointly are the judgement being offered. - One explanatory line is added to the menu, and only when something on it is actually tagged. Two deliberate calls: LABEL, DON'T FILTER. A stricter threshold for foreign-language hits would suppress exactly the shape-borrowing this exists for. They were never the problem; their being undisclosed was. ONLY CLAIM A MISMATCH YOU CAN ESTABLISH. _foreign_language returns "" when either side is unknown — unrecognised extension, or a snippet with no recorded language. A wrong "· python" is worse than no tag. Same-language hits stay unlabelled, so the common case keeps a clean line and the preamble stays off the menu entirely. Operator-typed language names fold through an alias table first (py/python3 → python, tsx → typescript, c++ → cpp); unrecognised names pass through lowercased, which still makes an unknown-but-equal pair compare equal. Trap found while building: _note() in the tests is a MagicMock, so `note.data` auto-created a truthy mock that would have rendered its repr into a menu line. Both test helpers now set data = None explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
213 lines
7.9 KiB
TypeScript
213 lines
7.9 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[];
|
|
/** 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;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** How often a record was put in front of an agent versus actually opened.
|
|
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
|
|
* slot in every future auto-inject menu while never being used. */
|
|
export interface SnippetUsage {
|
|
surfaced_count: number;
|
|
pull_count: number;
|
|
last_surfaced_at: string | null;
|
|
last_pulled_at: string | null;
|
|
}
|
|
|
|
/** 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;
|
|
/** 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;
|
|
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 });
|
|
}
|