Skip to content
Open
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
12 changes: 9 additions & 3 deletions src/hooks/grep-direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,18 @@ function getEmbedClient(): EmbedClient {
return sharedEmbedClient;
}

const MAX_SEMANTIC_ALTERNATIVES = 8;

function patternIsSemanticFriendly(pattern: string, fixedString: boolean): boolean {
if (!pattern || pattern.length < 2) return false;
if (fixedString) return true;
const meta = pattern.match(/[|()\[\]{}+?^$\\]/g);
if (!meta) return true;
return meta.length <= 1;
// `|` is not counted as a metacharacter: a synonym alternation like
// `data loss|concurrent writer|race condition` is exactly the kind of
// query embeddings answer best. Cap the number of alternatives so a
// pathological many-clause pattern still falls back to lexical.
const meta = pattern.match(/[()\[\]{}+?^$\\]/g);
if (meta && meta.length > 1) return false;
return pattern.split("|").length <= MAX_SEMANTIC_ALTERNATIVES;
}

export interface GrepParams {
Expand Down
11 changes: 8 additions & 3 deletions src/shell/grep-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ function getGrepEmbedClient(): EmbedClient {
return sharedGrepEmbedClient;
}

const MAX_SEMANTIC_ALTERNATIVES = 8;

/**
* Plain-text-ish pattern → candidate for semantic search.
* Skip regex-heavy queries (many metachars) where cosine similarity is not
Expand All @@ -45,9 +47,12 @@ function patternIsSemanticFriendly(pattern: string, fixedString: boolean): boole
if (!pattern || pattern.length < 2) return false;
if (fixedString) return true;
// Literal-ish patterns with only occasional `.*` are still fine for semantic.
const metaMatches = pattern.match(/[|()\[\]{}+?^$\\]/g);
if (!metaMatches) return true;
return metaMatches.length <= 1;
// `|` is not counted: synonym alternations (`foo|bar|baz`) are what
// embeddings are good at. Cap the number of alternatives so a
// pathological many-clause pattern still falls back to lexical.
const metaMatches = pattern.match(/[()\[\]{}+?^$\\]/g);
if (metaMatches && metaMatches.length > 1) return false;
return pattern.split("|").length <= MAX_SEMANTIC_ALTERNATIVES;
}

const MAX_FALLBACK_CANDIDATES = 500;
Expand Down
82 changes: 82 additions & 0 deletions tests/claude-code/grep-direct-semantic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, afterEach } from "vitest";

// Semantic gating for handleGrepDirect (the pre-tool-use fast path). The
// sibling grep-direct.test.ts pins embed() to null to keep its lexical
// assertions deterministic; this file stubs the embed client with a spy so
// it can assert *whether* a pattern is sent to the daemon at all.
const { mockEmbed } = vi.hoisted(() => ({ mockEmbed: vi.fn() }));
vi.mock("../../src/embeddings/client.js", () => ({
EmbedClient: class {
async embed(text: string, kind: string) { return mockEmbed(text, kind); }
async warmup() { return false; }
},
}));
vi.mock("../../src/embeddings/disable.js", () => ({
embeddingsDisabled: () => false,
embeddingsStatus: () => "enabled",
}));

import { handleGrepDirect, type GrepParams } from "../../src/hooks/grep-direct.js";

describe("handleGrepDirect: semantic pattern gating", () => {
const baseParams: GrepParams = {
pattern: "foo", targetPath: "/",
ignoreCase: false, wordMatch: false, filesOnly: false, countOnly: false,
lineNumber: false, invertMatch: false, fixedString: false,
};

function mockApi() {
return { query: vi.fn().mockResolvedValue([]) } as any;
}

function sqlOf(api: { query: ReturnType<typeof vi.fn> }): string {
return api.query.mock.calls.map(c => String(c[0])).join("\n");
}

afterEach(() => { mockEmbed.mockReset(); });

it("embeds synonym alternations and runs the hybrid query (issue #86)", async () => {
mockEmbed.mockResolvedValue([0.1, 0.2, 0.3]);
const api = mockApi();
await handleGrepDirect(api, "memory", "sessions", {
...baseParams, pattern: "silent data loss|concurrent writer|race condition",
});
expect(mockEmbed).toHaveBeenCalledWith("silent data loss|concurrent writer|race condition", "query");
expect(sqlOf(api)).toContain("<#>");
});

it("embeds plain patterns", async () => {
mockEmbed.mockResolvedValue([0.1]);
await handleGrepDirect(mockApi(), "memory", "sessions", { ...baseParams, pattern: "deploy failed" });
expect(mockEmbed).toHaveBeenCalledWith("deploy failed", "query");
});

it("skips embedding for regex-heavy patterns even when they contain `|`", async () => {
mockEmbed.mockResolvedValue([0.1]);
const api = mockApi();
await handleGrepDirect(api, "memory", "sessions", { ...baseParams, pattern: "(foo|bar)\\+" });
expect(mockEmbed).not.toHaveBeenCalled();
expect(sqlOf(api)).not.toContain("<#>");
});

it("skips embedding for alternations with more than 8 alternatives", async () => {
mockEmbed.mockResolvedValue([0.1]);
await handleGrepDirect(mockApi(), "memory", "sessions", {
...baseParams, pattern: "a1|a2|a3|a4|a5|a6|a7|a8|a9",
});
expect(mockEmbed).not.toHaveBeenCalled();
});

it("embeds an alternation at the 8-alternative limit", async () => {
mockEmbed.mockResolvedValue([0.1]);
await handleGrepDirect(mockApi(), "memory", "sessions", {
...baseParams, pattern: "a1|a2|a3|a4|a5|a6|a7|a8",
});
expect(mockEmbed).toHaveBeenCalled();
});

it("skips embedding for patterns shorter than 2 chars", async () => {
await handleGrepDirect(mockApi(), "memory", "sessions", { ...baseParams, pattern: "|" });
expect(mockEmbed).not.toHaveBeenCalled();
});
});
36 changes: 36 additions & 0 deletions tests/claude-code/grep-interceptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,42 @@ describe("grep interceptor", () => {
expect(mockEmbed).not.toHaveBeenCalled();
});

it("embeds synonym alternations like `foo|bar|baz` (issue #86)", async () => {
// `|` separates different surface forms of the same concept, which is
// the case embeddings are for. It must not count toward the
// regex-heavy metachar limit.
mockEmbed.mockResolvedValueOnce([0.4, 0.5, 0.6]);
const client = makeClient([]);
const fs = await DeeplakeFs.create(client as never, "test", "/memory");
const searchSpy = vi.spyOn(grepCore, "searchDeeplakeTables").mockResolvedValue([]);

const cmd = createGrepCommand(client as never, fs, "test", "sessions");
await cmd.execute(["data loss|concurrent writer|race condition", "/memory"], makeCtx(fs) as never);

expect(mockEmbed).toHaveBeenCalledWith("data loss|concurrent writer|race condition", "query");
const opts = searchSpy.mock.calls[0][3] as { queryEmbedding: number[] | null };
expect(opts.queryEmbedding).toEqual([0.4, 0.5, 0.6]);
searchSpy.mockRestore();
});

it("still embeds an alternation with one other metachar", async () => {
mockEmbed.mockResolvedValueOnce([0.1]);
const client = makeClient([]);
const fs = await DeeplakeFs.create(client as never, "test", "/memory");
const cmd = createGrepCommand(client as never, fs, "test");
await cmd.execute(["deploy failed|rollback?", "/memory"], makeCtx(fs) as never);
expect(mockEmbed).toHaveBeenCalled();
});

it("skips embedding on alternations with more than 8 alternatives", async () => {
mockEmbed.mockResolvedValue([0.5]);
const client = makeClient([]);
const fs = await DeeplakeFs.create(client as never, "test", "/memory");
const cmd = createGrepCommand(client as never, fs, "test");
await cmd.execute(["a1|a2|a3|a4|a5|a6|a7|a8|a9", "/memory"], makeCtx(fs) as never);
expect(mockEmbed).not.toHaveBeenCalled();
});

it("skips embedding on very short patterns (< 2 chars)", async () => {
mockEmbed.mockClear();
const client = makeClient([]);
Expand Down