Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/hooks/session-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -61,7 +64,7 @@ async function main(): Promise<void> {
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); });
28 changes: 0 additions & 28 deletions src/hooks/shared/recall-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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;
}
6 changes: 6 additions & 0 deletions src/notifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -115,6 +120,7 @@ export async function drainSessionStart(opts: DrainOptions): Promise<void> {
localSkillsCount: opts.localSkillsCount ?? null,
latestInsightEntry: opts.latestInsightEntry ?? null,
sessionCount: opts.sessionCount,
embeddingsStatus: opts.embeddingsStatus,
};

const fromRules = evaluateRules("session_start", ctx);
Expand Down
46 changes: 46 additions & 0 deletions src/notifications/rules/embeddings-nudge.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
efenocchi marked this conversation as resolved.
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.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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;
}
}
37 changes: 37 additions & 0 deletions tests/claude-code/notifications-embeddings-nudge.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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();
});
});
20 changes: 0 additions & 20 deletions tests/shared/recall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest";
import {
shouldRecall,
passesThreshold,
extractKeywords,
proactiveRecallDisabled,
parsePositive,
RECALL_THRESHOLD,
Expand Down Expand Up @@ -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); });
Expand Down