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/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/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..ebed08770 --- /dev/null +++ b/src/notifications/rules/embeddings-nudge.ts @@ -0,0 +1,46 @@ +/** + * Embeddings nudge — a one-time SessionStart banner telling users that + * semantic memory search is off because embeddings are not enabled. + * + * 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"; +import type { EmbeddingsStatus } from "../../embeddings/disable.js"; + +export const embeddingsNudgeRule: Rule = { + id: "embeddings-nudge", + trigger: "session_start", + evaluate({ embeddingsStatus }) { + // 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: "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 }, + }; + }, +}; + +// 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..d15584f01 --- /dev/null +++ b/tests/claude-code/notifications-embeddings-nudge.test.ts @@ -0,0 +1,37 @@ +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 }; +} + +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("Semantic memory search is off — embeddings not enabled"); + expect(n!.body).toBe(EXPECTED_BODY); + expect(n!.dedupKey).toEqual({ v: 1 }); + }); + + 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 embeddings are enabled", () => { + expect(embeddingsNudgeRule.evaluate(ctx({ embeddingsStatus: "enabled" }))).toBeNull(); + }); + + it("stays silent when embeddingsStatus is not provided (treat as enabled)", () => { + expect(embeddingsNudgeRule.evaluate(ctx({}))).toBeNull(); + }); +}); 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); });