diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts
index 249bd30..26c9bcd 100644
--- a/frontend/src/api/snippets.ts
+++ b/frontend/src/api/snippets.ts
@@ -154,6 +154,25 @@ export async function deleteSnippet(id: number): Promise {
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. */
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 1321cc4..4d27527 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -23,6 +23,10 @@ const kbInjectEnabled = ref(true);
const kbInjectThreshold = ref("0.55");
const kbInjectTopK = ref("3");
const kbWritePathEnabled = ref(true);
+// Near-duplicate report floor. Deliberately looser than the 0.90 write-time
+// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
+// suggests a merge the operator reviews (services/dedup.py).
+const kbDuplicateThreshold = ref("0.82");
const savingKbInject = ref(false);
const kbInjectSaved = ref(false);
@@ -68,8 +72,13 @@ async function saveRetention() {
async function saveKbInject() {
const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0));
const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1)));
+ // `|| 0.82` not `|| 0`: an unparseable value here should fall back to the
+ // default, not to 0 — a 0 floor would report every snippet as a duplicate of
+ // every other one.
+ const dupT = Math.min(1, Math.max(0, Number(kbDuplicateThreshold.value) || 0.82));
kbInjectThreshold.value = String(t);
kbInjectTopK.value = String(k);
+ kbDuplicateThreshold.value = String(dupT);
savingKbInject.value = true;
kbInjectSaved.value = false;
try {
@@ -80,6 +89,7 @@ async function saveKbInject() {
// Its own switch, but deliberately the same threshold/ceiling — see
// WRITEPATH_ENABLED_KEY in services/plugin_context.py.
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
+ kb_duplicate_threshold: String(dupT),
});
kbInjectSaved.value = true;
setTimeout(() => (kbInjectSaved.value = false), 2000);
@@ -464,6 +474,9 @@ onMounted(async () => {
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
}
kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
+ if (allSettings.kb_duplicate_threshold !== undefined) {
+ kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
+ }
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -1211,6 +1224,25 @@ function formatUserDate(iso: string): string {
edit. Off = prior art surfaces only on your own prompts.
+
+
+
+
+ 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.
+