diff --git a/alembic/versions/0071_note_usage_events.py b/alembic/versions/0071_note_usage_events.py
new file mode 100644
index 0000000..c9ac3cb
--- /dev/null
+++ b/alembic/versions/0071_note_usage_events.py
@@ -0,0 +1,72 @@
+"""add note_usage_events — did anyone actually open what we surfaced?
+
+Revision ID: 0071
+Revises: 0070
+Create Date: 2026-07-28
+
+`retrieval_logs` records what the ranker returned and with what scores, which is
+the right substrate for tuning a similarity threshold. It cannot answer the
+different question the snippet corpus needs: was a surfaced snippet ever pulled
+in full? A snippet nobody opens still competes for the injection budget on every
+turn, so the surfaced:pulled ratio is what makes dead weight visible.
+
+Two reasons this is its own table rather than columns on `notes` or rows in
+`retrieval_logs`:
+
+ - Counters on `notes` would answer "how many" but not "when, from where, and
+ by which arm" — and the place arm vs semantic arm comparison is precisely
+ what was missing (the write-path place arm surfaced snippets while leaving
+ no trace anywhere).
+ - Folding un-scored surfacing into `retrieval_logs` would corrupt the score
+ distribution that table exists to capture. Location hits have no score.
+
+Grain is one row per note per event, which is what the per-snippet readout needs
+and what `retrieval_logs.result_ids` (a JSONB array, one row per *call*) cannot
+be indexed at.
+
+FK-free on note_id and user_id, matching retrieval_logs and app_logs: telemetry
+should outlive what it describes. Deleting a note must not erase the evidence
+that it was surfaced forty times and opened none.
+
+Downgrade drops the table outright. The data is purely observational — nothing
+reads it for correctness, so losing it costs history and no behavior.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0071"
+down_revision = "0070"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "note_usage_events",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column("user_id", sa.Integer(), nullable=True),
+ sa.Column("note_id", sa.Integer(), nullable=False),
+ sa.Column("event", sa.Text(), nullable=False),
+ sa.Column("source", sa.Text(), nullable=False),
+ )
+ # Every readout is "these note ids, split by event", so the composite is the
+ # one that actually gets used; the others serve pruning and per-user views.
+ op.create_index(
+ "ix_note_usage_note_event", "note_usage_events", ["note_id", "event"]
+ )
+ op.create_index("ix_note_usage_created_at", "note_usage_events", ["created_at"])
+ op.create_index("ix_note_usage_user_id", "note_usage_events", ["user_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_note_usage_user_id", table_name="note_usage_events")
+ op.drop_index("ix_note_usage_created_at", table_name="note_usage_events")
+ op.drop_index("ix_note_usage_note_event", table_name="note_usage_events")
+ op.drop_table("note_usage_events")
diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts
index fa7ef03..0fae70b 100644
--- a/frontend/src/api/snippets.ts
+++ b/frontend/src/api/snippets.ts
@@ -20,9 +20,13 @@ export interface SnippetFields {
path: string;
symbol: string;
locations: SnippetLocation[];
- /** Ids of 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. */
- merged_from: number[];
+ /** 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;
}
@@ -46,6 +50,29 @@ export interface Snippet {
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. */
@@ -61,6 +88,11 @@ export interface SnippetListItem {
* them, not one of your own. Absent means it's yours. */
shared?: boolean;
owner?: string | null;
+ /** 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
@@ -91,6 +123,8 @@ export async function listSnippets(
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();
@@ -100,6 +134,7 @@ export async function listSnippets(
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}` : ""}`);
}
@@ -123,6 +158,45 @@ export async function deleteSnippet(id: number): Promise
+ How alike two snippets must be before the Snippets page suggests merging + them. Lower = more suggestions, more false pairs. Looser than the 0.90 + used to block a duplicate at creation, because this only proposes a merge + you review — it never acts on its own. +
++ No near-duplicates found. Nothing recorded resembles anything else closely + enough to be worth merging. +
+ ++ {{ duplicateGroups.length }} possible duplicate{{ duplicateGroups.length > 1 ? " sets" : " set" }} + — review each before merging; a set is a suggestion, not a verdict. +
+- {{ locationActive - ? "Nothing kept at that location yet" - : search.trim() - ? "No snippets match your search" - : "No snippets kept yet" }} + {{ needsAttentionOnly + ? "Everything checks out" + : locationActive + ? "Nothing kept at that location yet" + : search.trim() + ? "No snippets match your search" + : "No snippets kept yet" }}
- {{ locationActive - ? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter." - : search.trim() - ? "Try a different term, or clear the search." - : "Record a reusable function or component and it will be offered back to you later." }} + {{ needsAttentionOnly + ? "No snippet has drifted from its recorded location or code — as far as anything has been checked. Snippets nobody has verified yet don't appear here." + : locationActive + ? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter." + : search.trim() + ? "Try a different term, or clear the search." + : "Record a reusable function or component and it will be offered back to you later." }}
-