From f1dc07961e426790131b7c74bf0d0719f188d8dc Mon Sep 17 00:00:00 2001 From: sumitvairagar Date: Mon, 14 Sep 2026 10:07:49 +0530 Subject: [PATCH 1/3] cleanup: remove dead extractKeywords() and STOPWORDS orphaned by ILIKE removal (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractKeywords() was documented as the lexical fallback keyword extractor, but the lexical fallback was removed in bae7bbb1 (semantic-only proactive recall, drop ILIKE fallback). e98aa533 cleaned up stale comments in recall.ts but left this helper and its STOPWORDS set behind. Zero production callers remain — the only references outside the definition were the test file (tests/shared/recall.test.ts:495-511). STOPWORDS is private to this function and unused elsewhere (SUMMARY_STOPWORDS in mine-local.ts is a separate, unrelated constant). Changes: - Delete STOPWORDS set and extractKeywords() from recall-gate.ts - Remove extractKeywords import and its describe block from recall.test.ts Test count: 58/58 pass (was 61 — 3 extractKeywords tests removed). TypeScript: tsc --noEmit clean. --- src/hooks/shared/recall-gate.ts | 28 ---------------------------- tests/shared/recall.test.ts | 20 -------------------- 2 files changed, 48 deletions(-) diff --git a/src/hooks/shared/recall-gate.ts b/src/hooks/shared/recall-gate.ts index ca61796b3..a3e889ca2 100644 --- a/src/hooks/shared/recall-gate.ts +++ b/src/hooks/shared/recall-gate.ts @@ -134,32 +134,4 @@ export const RECALL_THRESHOLD: number = (() => { return Number.isFinite(n) && n > 0 && n <= 1 ? n : DEFAULT_RECALL_THRESHOLD; })(); -// Common words carry no recall signal — matching them would surface noise. -const STOPWORDS = new Set([ - "the", "and", "for", "are", "but", "not", "you", "your", "with", "this", "that", - "have", "has", "had", "was", "were", "can", "could", "should", "would", "will", - "does", "did", "what", "why", "how", "when", "where", "which", "who", "into", - "from", "they", "them", "then", "than", "there", "here", "out", "get", "got", - "use", "using", "used", "make", "made", "want", "need", "please", "let", "add", - "fix", "run", "set", "all", "any", "our", "its", "his", "her", "now", "new", - "some", "more", "most", "such", "only", "also", "just", "like", "able", "via", -]); -/** - * Extract salient lower-cased keywords from a prompt for the lexical fallback. - * Keeps identifier-ish tokens (snake_case, dotted, paths), drops stopwords and - * sub-3-char tokens, de-dupes, and caps the count. - */ -export function extractKeywords(prompt: string | undefined | null, max = 8): string[] { - const raw = (prompt ?? "").toLowerCase().match(/[a-z0-9][a-z0-9_./-]{2,}/g) ?? []; - const out: string[] = []; - const seen = new Set(); - for (const tok of raw) { - const w = tok.replace(/[._/-]+$/, ""); // trim trailing separators - if (w.length < 3 || STOPWORDS.has(w) || seen.has(w)) continue; - seen.add(w); - out.push(w); - if (out.length >= max) break; - } - return out; -} diff --git a/tests/shared/recall.test.ts b/tests/shared/recall.test.ts index 0d3c7f17f..e241858f4 100644 --- a/tests/shared/recall.test.ts +++ b/tests/shared/recall.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest"; import { shouldRecall, passesThreshold, - extractKeywords, proactiveRecallDisabled, parsePositive, RECALL_THRESHOLD, @@ -492,25 +491,6 @@ describe("recallTopHit — focused semantic query", () => { }); }); -describe("extractKeywords — lexical fallback keyword extraction", () => { - it("keeps salient/identifier tokens, drops stopwords and short tokens", () => { - const kw = extractKeywords("why does the parser throw a TypeError in column_streamers.hpp?"); - expect(kw).toContain("parser"); - expect(kw).toContain("typeerror"); - expect(kw).toContain("column_streamers.hpp"); - expect(kw).not.toContain("the"); - expect(kw).not.toContain("why"); // stopword - }); - it("de-dupes and caps the count", () => { - const kw = extractKeywords("cache cache cache redis redis storage storage provider bucket byoc extra", 4); - expect(kw.length).toBe(4); - expect(new Set(kw).size).toBe(kw.length); - }); - it("returns few/no keywords for terse input (can't meet the lexical bar)", () => { - expect(extractKeywords("ok go").length).toBeLessThan(2); - }); -}); - describe("recordRecallEvent — always-on JSONL sink", () => { let home: string; beforeEach(() => { home = mkdtempSync(join(tmpdir(), "recall-ev-")); setFakeHome(home); }); From 88dce8fae5a18297223fca8e12ee3a86c0fa945a Mon Sep 17 00:00:00 2001 From: sumitvairagar Date: Tue, 15 Sep 2026 17:39:08 +0530 Subject: [PATCH 2/3] fix(recall): notify once at SessionStart when embeddings not installed (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a default install @huggingface/transformers is absent, so embeddingsDisabled() returns true and the recall hook silently no-ops on every prompt — the headline feature of Hivemind is inactive with no user-visible signal. Fix: add a one-time warn notification via the existing notifications framework that fires at SessionStart when status is 'no-transformers'. Suppressed when the user explicitly opted out ('user-disabled') — they made an intentional choice. Changes: - src/notifications/rules/embeddings-nudge.ts — new Rule, pure, no IO - src/notifications/index.ts — thread embeddingsStatus through DrainOptions → NotificationContext - src/hooks/session-notifications.ts — register rule, pass embeddingsStatus() - tests/claude-code/notifications-embeddings-nudge.test.ts — 4 cases: fires on no-transformers, silent on enabled, silent on user-disabled, silent when absent 4/4 new tests pass. tsc --noEmit clean. Pre-existing timeout in notifications.test.ts (bundle artifact test) unchanged. --- src/hooks/session-notifications.ts | 5 +- src/notifications/index.ts | 6 +++ src/notifications/rules/embeddings-nudge.ts | 50 +++++++++++++++++++ .../notifications-embeddings-nudge.test.ts | 32 ++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/notifications/rules/embeddings-nudge.ts create mode 100644 tests/claude-code/notifications-embeddings-nudge.test.ts diff --git a/src/hooks/session-notifications.ts b/src/hooks/session-notifications.ts index f11746e76..4d2818993 100644 --- a/src/hooks/session-notifications.ts +++ b/src/hooks/session-notifications.ts @@ -17,6 +17,8 @@ import { readStdin } from "../utils/stdin.js"; import { drainSessionStart, registerRule } from "../notifications/index.js"; import { bumpSessionCount } from "../notifications/state.js"; import { referralInviteRule } from "../notifications/rules/referral-invite.js"; +import { embeddingsNudgeRule } from "../notifications/rules/embeddings-nudge.js"; +import { embeddingsStatus } from "../embeddings/disable.js"; import { log as _log } from "../utils/debug.js"; const log = (msg: string) => _log("session-notifications", msg); @@ -27,6 +29,7 @@ const log = (msg: string) => _log("session-notifications", msg); // on, for signed-in users (see rules/referral-invite.ts). localMinedRule // remains in the tree but unregistered. registerRule(referralInviteRule); +registerRule(embeddingsNudgeRule); interface SessionStartInput { session_id?: string; @@ -61,7 +64,7 @@ async function main(): Promise { const sessionCount = bumpSessionCount(sessionId); const creds = loadCredentials(); - await drainSessionStart({ agent: "claude-code", creds, sessionId, source, sessionCount }); + await drainSessionStart({ agent: "claude-code", creds, sessionId, source, sessionCount, embeddingsStatus: embeddingsStatus() }); } main().catch((e) => { log(`fatal: ${e?.message ?? String(e)}`); process.exit(0); }); diff --git a/src/notifications/index.ts b/src/notifications/index.ts index 650eba653..2da915bed 100644 --- a/src/notifications/index.ts +++ b/src/notifications/index.ts @@ -76,6 +76,11 @@ export interface DrainOptions { * entry point via bumpSessionCount so rules stay IO-free. */ sessionCount?: number; + /** + * Pre-read embeddings status — populated by the hook entry point so rules + * stay IO-free. When absent, treated as "enabled" (no nudge fired). + */ + embeddingsStatus?: import("../embeddings/disable.js").EmbeddingsStatus; /** * Delivery override. When set, the claimed notifications are handed to * this function instead of the per-agent adapter in delivery/index.ts. @@ -115,6 +120,7 @@ export async function drainSessionStart(opts: DrainOptions): Promise { localSkillsCount: opts.localSkillsCount ?? null, latestInsightEntry: opts.latestInsightEntry ?? null, sessionCount: opts.sessionCount, + embeddingsStatus: opts.embeddingsStatus, }; const fromRules = evaluateRules("session_start", ctx); diff --git a/src/notifications/rules/embeddings-nudge.ts b/src/notifications/rules/embeddings-nudge.ts new file mode 100644 index 000000000..8fa479568 --- /dev/null +++ b/src/notifications/rules/embeddings-nudge.ts @@ -0,0 +1,50 @@ +/** + * Embeddings-disabled nudge — a one-time SessionStart banner telling users + * that proactive recall is off because embeddings aren't installed. + * + * On a fresh marketplace install, `@huggingface/transformers` is absent and + * the recall hook silently no-ops on every prompt — the headline feature of + * Hivemind is inactive with no user-visible signal. This rule fires once to + * surface the problem and point at the fix. + * + * Fires when: embeddings are in the "no-transformers" state (not installed, + * not a user opt-out). Suppressed when the user explicitly disabled + * embeddings — they made an intentional choice and don't need a nudge. + * + * Shown exactly once (stable dedupKey). If the user installs embeddings and + * then uninstalls them again, the nudge won't re-fire — acceptable given the + * rarity of that path. + */ + +import type { Rule } from "../types.js"; +import type { EmbeddingsStatus } from "../../embeddings/disable.js"; + +export const embeddingsNudgeRule: Rule = { + id: "embeddings-nudge", + trigger: "session_start", + evaluate({ embeddingsStatus }) { + // Only nudge when transformers aren't installed — not when the user + // explicitly opted out (user-disabled) or when embeddings are working. + if (embeddingsStatus !== "no-transformers") return null; + return { + id: "embeddings-nudge", + severity: "warn", + title: "Proactive recall is off — embeddings not installed", + body: "Run `hivemind embeddings install` to enable semantic memory search. Until then, proactive recall silently skips every prompt.", + // Stable key → shown once, ever. + dedupKey: { v: 1 }, + }; + }, +}; + +// Extend NotificationContext with the embeddings status field. +// Declared here to keep the rule self-contained; the hook entry point +// populates it before calling drainSessionStart. +declare module "../types.js" { + interface NotificationContext { + /** Pre-read embeddings status — populated by the hook entry point so the + * rule stays IO-free. Undefined when the entry point doesn't provide it + * (e.g. older test harnesses); treated as "enabled" (no nudge). */ + embeddingsStatus?: EmbeddingsStatus; + } +} diff --git a/tests/claude-code/notifications-embeddings-nudge.test.ts b/tests/claude-code/notifications-embeddings-nudge.test.ts new file mode 100644 index 000000000..bdbf1697b --- /dev/null +++ b/tests/claude-code/notifications-embeddings-nudge.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; + +import { embeddingsNudgeRule } from "../../src/notifications/rules/embeddings-nudge.js"; +import type { NotificationContext } from "../../src/notifications/types.js"; + +function ctx(over: Partial): NotificationContext { + return { agent: "claude-code", creds: null, state: { shown: {} }, ...over }; +} + +describe("embeddingsNudgeRule", () => { + it("fires when transformers are not installed", () => { + const n = embeddingsNudgeRule.evaluate(ctx({ embeddingsStatus: "no-transformers" })); + expect(n).not.toBeNull(); + expect(n!.id).toBe("embeddings-nudge"); + expect(n!.severity).toBe("warn"); + expect(n!.title).toBe("Proactive recall is off — embeddings not installed"); + expect(n!.body).toContain("hivemind embeddings install"); + expect(n!.dedupKey).toEqual({ v: 1 }); + }); + + it("stays silent when embeddings are enabled", () => { + expect(embeddingsNudgeRule.evaluate(ctx({ embeddingsStatus: "enabled" }))).toBeNull(); + }); + + it("stays silent when the user explicitly disabled embeddings (intentional opt-out)", () => { + expect(embeddingsNudgeRule.evaluate(ctx({ embeddingsStatus: "user-disabled" }))).toBeNull(); + }); + + it("stays silent when embeddingsStatus is not provided (treat as enabled)", () => { + expect(embeddingsNudgeRule.evaluate(ctx({}))).toBeNull(); + }); +}); From 31c6aacf6118a75cb7bcec6e0bd99db2bd2152fe Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Wed, 16 Sep 2026 22:09:25 +0000 Subject: [PATCH 3/3] fix(notifications): fire the embeddings nudge on the default install too embeddingsStatus() reads the enabled flag before it probes for transformers, and that flag is seeded false on first read, so a fresh install reports "user-disabled" rather than "no-transformers" and the nudge never fired on the scenario #338 describes. Gate on anything that is not "enabled" and let the stable dedupKey keep it to one banner. Reword the copy around semantic memory search instead of the proactive recall hook, which is being removed separately, and assert the full body in the test as CodeRabbit asked. --- src/notifications/rules/embeddings-nudge.ts | 34 ++++++++----------- .../notifications-embeddings-nudge.test.ts | 17 ++++++---- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/notifications/rules/embeddings-nudge.ts b/src/notifications/rules/embeddings-nudge.ts index 8fa479568..ebed08770 100644 --- a/src/notifications/rules/embeddings-nudge.ts +++ b/src/notifications/rules/embeddings-nudge.ts @@ -1,19 +1,15 @@ /** - * Embeddings-disabled nudge — a one-time SessionStart banner telling users - * that proactive recall is off because embeddings aren't installed. + * Embeddings nudge — a one-time SessionStart banner telling users that + * semantic memory search is off because embeddings are not enabled. * - * On a fresh marketplace install, `@huggingface/transformers` is absent and - * the recall hook silently no-ops on every prompt — the headline feature of - * Hivemind is inactive with no user-visible signal. This rule fires once to - * surface the problem and point at the fix. - * - * Fires when: embeddings are in the "no-transformers" state (not installed, - * not a user opt-out). Suppressed when the user explicitly disabled - * embeddings — they made an intentional choice and don't need a nudge. - * - * Shown exactly once (stable dedupKey). If the user installs embeddings and - * then uninstalls them again, the nudge won't re-fire — acceptable given the - * rarity of that path. + * Embeddings default to off (`embeddings.enabled` is seeded `false` in + * ~/.deeplake/config.json on first read, see user-config.ts), and the + * transformers deps ship separately via `hivemind embeddings install`. So on + * a fresh install `embeddingsStatus()` reports "user-disabled" even though + * the user never chose anything, and the semantic half of memory search is + * silently inactive. The status enum cannot tell that default apart from a + * deliberate `hivemind embeddings disable`, so the rule fires on every + * non-enabled state and relies on the stable dedupKey to show at most once. */ import type { Rule } from "../types.js"; @@ -23,14 +19,14 @@ export const embeddingsNudgeRule: Rule = { id: "embeddings-nudge", trigger: "session_start", evaluate({ embeddingsStatus }) { - // Only nudge when transformers aren't installed — not when the user - // explicitly opted out (user-disabled) or when embeddings are working. - if (embeddingsStatus !== "no-transformers") return null; + // Undefined means the entry point did not provide a status (older + // harness wiring); treat as enabled and stay quiet. + if (embeddingsStatus === undefined || embeddingsStatus === "enabled") return null; return { id: "embeddings-nudge", severity: "warn", - title: "Proactive recall is off — embeddings not installed", - body: "Run `hivemind embeddings install` to enable semantic memory search. Until then, proactive recall silently skips every prompt.", + title: "Semantic memory search is off — embeddings not enabled", + body: "Run `hivemind embeddings install` to enable semantic search over your team's memory. Until then, memory search is keyword-only.", // Stable key → shown once, ever. dedupKey: { v: 1 }, }; diff --git a/tests/claude-code/notifications-embeddings-nudge.test.ts b/tests/claude-code/notifications-embeddings-nudge.test.ts index bdbf1697b..d15584f01 100644 --- a/tests/claude-code/notifications-embeddings-nudge.test.ts +++ b/tests/claude-code/notifications-embeddings-nudge.test.ts @@ -7,23 +7,28 @@ function ctx(over: Partial): NotificationContext { return { agent: "claude-code", creds: null, state: { shown: {} }, ...over }; } +const EXPECTED_BODY = + "Run `hivemind embeddings install` to enable semantic search over your team's memory. Until then, memory search is keyword-only."; + describe("embeddingsNudgeRule", () => { it("fires when transformers are not installed", () => { const n = embeddingsNudgeRule.evaluate(ctx({ embeddingsStatus: "no-transformers" })); expect(n).not.toBeNull(); expect(n!.id).toBe("embeddings-nudge"); expect(n!.severity).toBe("warn"); - expect(n!.title).toBe("Proactive recall is off — embeddings not installed"); - expect(n!.body).toContain("hivemind embeddings install"); + expect(n!.title).toBe("Semantic memory search is off — embeddings not enabled"); + expect(n!.body).toBe(EXPECTED_BODY); expect(n!.dedupKey).toEqual({ v: 1 }); }); - it("stays silent when embeddings are enabled", () => { - expect(embeddingsNudgeRule.evaluate(ctx({ embeddingsStatus: "enabled" }))).toBeNull(); + it("fires on the default install, where the flag is seeded false and reads as user-disabled", () => { + const n = embeddingsNudgeRule.evaluate(ctx({ embeddingsStatus: "user-disabled" })); + expect(n).not.toBeNull(); + expect(n!.body).toBe(EXPECTED_BODY); }); - it("stays silent when the user explicitly disabled embeddings (intentional opt-out)", () => { - expect(embeddingsNudgeRule.evaluate(ctx({ embeddingsStatus: "user-disabled" }))).toBeNull(); + it("stays silent when embeddings are enabled", () => { + expect(embeddingsNudgeRule.evaluate(ctx({ embeddingsStatus: "enabled" }))).toBeNull(); }); it("stays silent when embeddingsStatus is not provided (treat as enabled)", () => {