diff --git a/.gitignore b/.gitignore
index 493d7b8afe8..a43a19059df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -137,6 +137,7 @@ tmp/
# code graph skills
.venv
.codegraph
+.zvec-grep/
.qwen/computer-use/installed.json
# Auto-generated computer-use marker can also appear under nested packages.
**/.qwen/computer-use/
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index c995ac20bc2..34a72818ca8 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -21,7 +21,7 @@ import {
SessionIdConflictError,
type CliArgs,
} from './config.js';
-import type { Settings } from './settings.js';
+import { LoadedSettings, SettingScope, type Settings } from './settings.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { resetMcpApprovalsForTesting } from './mcpApprovals.js';
@@ -2546,6 +2546,53 @@ describe('mergeExcludeTools', () => {
expect(config.getPermissionsDeny()).toContain('tool_search');
});
+ it('should leave zvec_grep disabled by default', async () => {
+ process.argv = ['node', 'script.js'];
+ const argv = await parseArguments();
+ const settings: Settings = {};
+ const config = await loadCliConfig(settings, argv, undefined, []);
+ expect(config.isZvecGrepEnabled()).toBe(false);
+ });
+
+ it('should enable zvec_grep when tools.zvecGrep.enabled is true', async () => {
+ process.argv = ['node', 'script.js'];
+ const argv = await parseArguments();
+ const settings: Settings = {
+ tools: { zvecGrep: { enabled: true } },
+ };
+ const config = await loadCliConfig(settings, argv, undefined, []);
+ expect(config.isZvecGrepEnabled()).toBe(true);
+ });
+
+ it('should persist the zvec_grep workspace opt-out', async () => {
+ vi.mocked(isWorkspaceTrusted).mockReturnValue({
+ isTrusted: true,
+ source: 'file',
+ });
+ const setValue = vi
+ .spyOn(LoadedSettings.prototype, 'setValue')
+ .mockImplementation(() => {});
+ process.argv = ['node', 'script.js'];
+ const argv = await parseArguments();
+ const settings: Settings = {
+ tools: { zvecGrep: { enabled: true } },
+ };
+ try {
+ const config = await loadCliConfig(settings, argv, undefined, []);
+
+ expect(config.canDisableZvecGrepForWorkspace()).toBe(true);
+ await config.disableZvecGrepForWorkspace();
+
+ expect(setValue).toHaveBeenCalledWith(
+ SettingScope.Workspace,
+ 'tools.zvecGrep.enabled',
+ false,
+ );
+ } finally {
+ setValue.mockRestore();
+ }
+ });
+
it('should auto-disable tool_search for deepseek-v4 models', async () => {
process.argv = ['node', 'script.js', '--model', 'deepseek-v4-flash'];
const argv = await parseArguments();
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index d27226420ef..97563779cfc 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -2110,6 +2110,16 @@ export async function loadCliConfig(
disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined,
disabledSkillNamesProvider:
bareMode || safeMode ? undefined : disabledSkillNamesProvider,
+ zvecGrepEnabled:
+ bareMode || safeMode ? false : settings.tools?.zvecGrep?.enabled === true,
+ onDisableZvecGrepForWorkspace: async () => {
+ const currentSettings = loadSettings(cwd);
+ currentSettings.setValue(
+ SettingScope.Workspace,
+ 'tools.zvecGrep.enabled',
+ false,
+ );
+ },
terminalImageRenderSupportProvider: interactive
? async () => {
const { getTerminalImageRenderSupport } = await import(
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 73888e84be1..ecbfb263311 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -2521,6 +2521,27 @@ const SETTINGS_SCHEMA = {
},
},
},
+ zvecGrep: {
+ type: 'object',
+ label: 'Zvec Grep',
+ category: 'Tools',
+ requiresRestart: true,
+ default: {},
+ description: 'Settings for the zvec-grep built-in search tool.',
+ showInDialog: false,
+ properties: {
+ enabled: {
+ type: 'boolean',
+ label: 'Enable Zvec Grep',
+ category: 'Tools',
+ requiresRestart: true,
+ default: false,
+ description:
+ 'When enabled, registers the zvec_grep built-in tool. Disabled by default.',
+ showInDialog: false,
+ },
+ },
+ },
shell: {
type: 'object',
label: 'Shell',
diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js
index b98ea4f1902..1ff21cd26f1 100644
--- a/packages/cli/src/i18n/locales/en.js
+++ b/packages/cli/src/i18n/locales/en.js
@@ -182,6 +182,7 @@ export default {
'toolDisplayName.ReadFile': 'toolDisplayName.ReadFile',
'toolDisplayName.ZoomImage': 'toolDisplayName.ZoomImage',
'toolDisplayName.Grep': 'toolDisplayName.Grep',
+ 'toolDisplayName.ZvecGrep': 'toolDisplayName.ZvecGrep',
'toolDisplayName.Glob': 'toolDisplayName.Glob',
'toolDisplayName.Shell': 'toolDisplayName.Shell',
'toolDisplayName.Shell Command': 'toolDisplayName.Shell Command',
diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js
index a92b7918d6f..036926ca795 100644
--- a/packages/cli/src/i18n/locales/zh-TW.js
+++ b/packages/cli/src/i18n/locales/zh-TW.js
@@ -173,6 +173,7 @@ export default {
'toolDisplayName.ReadFile': '讀取檔案',
'toolDisplayName.ZoomImage': '縮放圖像',
'toolDisplayName.Grep': 'Grep',
+ 'toolDisplayName.ZvecGrep': '語義搜尋',
'toolDisplayName.Glob': 'Glob',
'toolDisplayName.Shell': '運行命令',
'toolDisplayName.Shell Command': 'Shell 命令',
diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js
index 5b8c0901266..10582cf431c 100644
--- a/packages/cli/src/i18n/locales/zh.js
+++ b/packages/cli/src/i18n/locales/zh.js
@@ -174,6 +174,7 @@ export default {
'toolDisplayName.ReadFile': '读取文件',
'toolDisplayName.ZoomImage': '缩放图像',
'toolDisplayName.Grep': 'Grep',
+ 'toolDisplayName.ZvecGrep': '语义搜索',
'toolDisplayName.Glob': 'Glob',
'toolDisplayName.Shell': '运行命令',
'toolDisplayName.Shell Command': 'Shell 命令',
diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx
index 7253982e186..98aae6fec60 100644
--- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx
+++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx
@@ -173,6 +173,22 @@ describe('', () => {
expect(lastFrame()).toContain('Type something...');
});
+ it('hides custom input when the question only supports fixed choices', () => {
+ const details = createConfirmationDetails({
+ questions: [createSingleQuestion({ allowCustomInput: false })],
+ });
+ const onConfirm = vi.fn();
+
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).not.toContain('Type something...');
+ });
+
it('renders help text for single select', () => {
const details = createConfirmationDetails();
const onConfirm = vi.fn();
diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx
index 551539a1261..54c3fa2d7bb 100644
--- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx
+++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx
@@ -88,13 +88,17 @@ export const AskUserQuestionDialog: React.FC = ({
? null
: confirmationDetails.questions[currentQuestionIndex];
const isMultiSelect = currentQuestion?.multiSelect ?? false;
+ const allowCustomInput = currentQuestion?.allowCustomInput !== false;
// Options + custom input ("Other")
- const totalOptions = currentQuestion ? currentQuestion.options.length + 1 : 2;
+ const totalOptions = currentQuestion
+ ? currentQuestion.options.length + (allowCustomInput ? 1 : 0)
+ : 2;
// Check if the custom input option is selected
const isCustomInputSelected =
!isSubmitTab &&
currentQuestion &&
+ allowCustomInput &&
selectedIndex === currentQuestion.options.length;
const getCustomInputValue = (idx: number) =>
@@ -103,6 +107,7 @@ export const AskUserQuestionDialog: React.FC = ({
const isCustomInputAnswer =
!isSubmitTab &&
currentQuestion &&
+ allowCustomInput &&
!isMultiSelect &&
selectedOptions[currentQuestionIndex] !== undefined &&
!currentQuestion.options.some(
@@ -331,7 +336,10 @@ export const AskUserQuestionDialog: React.FC = ({
// Handle multi-select: Enter advances to next question / submits
if (isMultiSelect && currentQuestion) {
// Custom input is handled by TextInput's onSubmit
- if (selectedIndex === currentQuestion.options.length) {
+ if (
+ allowCustomInput &&
+ selectedIndex === currentQuestion.options.length
+ ) {
return;
}
handleMultiSelectSubmit();
@@ -523,7 +531,10 @@ export const AskUserQuestionDialog: React.FC = ({
})}
{/* Type something option/input */}
-
+
{isCustomInputSelected ? (
// Inline TextInput replaces the option text
diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts
index 445c7e0491f..94d3fafdc17 100644
--- a/packages/core/src/config/config.test.ts
+++ b/packages/core/src/config/config.test.ts
@@ -8542,6 +8542,34 @@ describe('setApprovalMode with folder trust', () => {
vi.clearAllMocks();
});
+ it('should not register zvec-grep tool by default', async () => {
+ const config = new Config({ ...baseParams, useRipgrep: false });
+ await config.initialize();
+
+ const calls = (ToolRegistry.prototype.registerFactory as Mock).mock.calls;
+ const zvecGrepRegistrations = calls.filter(
+ (call) => call[0] === ToolNames.ZVEC_GREP,
+ );
+
+ expect(zvecGrepRegistrations.length).toBe(0);
+ });
+
+ it('should register zvec-grep tool when enabled', async () => {
+ const config = new Config({
+ ...baseParams,
+ useRipgrep: false,
+ zvecGrepEnabled: true,
+ });
+ await config.initialize();
+
+ const calls = (ToolRegistry.prototype.registerFactory as Mock).mock.calls;
+ const zvecGrepRegistrations = calls.filter(
+ (call) => call[0] === ToolNames.ZVEC_GREP,
+ );
+
+ expect(zvecGrepRegistrations.length).toBe(1);
+ });
+
it('registers the background-agent roster tool', async () => {
const config = new Config(baseParams);
await config.initialize();
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 3c024bc4f8b..d33b12af078 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -967,6 +967,9 @@ export interface ConfigParameters {
* Names returned must be lower-cased; consumers compare case-insensitively.
*/
disabledSkillNamesProvider?: () => ReadonlySet;
+ zvecGrepEnabled?: boolean;
+ /** Persists a workspace-scoped opt-out selected from zvec-grep setup. */
+ onDisableZvecGrepForWorkspace?: () => Promise;
terminalImageRenderSupportProvider?: () => Promise;
/**
* Skill discovery levels that should not be loaded. Sourced from
@@ -1813,6 +1816,7 @@ export class Config {
private readonly disabledSkillNamesProvider:
| (() => ReadonlySet)
| null;
+ private readonly zvecGrepEnabled: boolean;
private readonly terminalImageRenderSupportProvider:
| (() => Promise)
| null;
@@ -2022,6 +2026,7 @@ export class Config {
ruleType: 'allow' | 'ask' | 'deny',
rule: string,
) => Promise;
+ private readonly onDisableZvecGrepForWorkspaceCallback?: () => Promise;
private initialized: boolean = false;
private initializationPromise?: Promise;
private initializationSucceeded = false;
@@ -2132,6 +2137,7 @@ export class Config {
...(params.disabledSlashCommands ?? []),
]);
this.disabledSkillNamesProvider = params.disabledSkillNamesProvider ?? null;
+ this.zvecGrepEnabled = params.zvecGrepEnabled ?? false;
this.terminalImageRenderSupportProvider =
params.terminalImageRenderSupportProvider ?? null;
this.disabledSkillLevels = new Set(params.disabledSkillLevels ?? []);
@@ -2303,6 +2309,8 @@ export class Config {
this.allowedHttpHookUrls = params.allowedHttpHookUrls ?? [];
this.allowPrivateNetworkHooks = params.allowPrivateNetworkHooks ?? false;
this.onPersistPermissionRuleCallback = params.onPersistPermissionRule;
+ this.onDisableZvecGrepForWorkspaceCallback =
+ params.onDisableZvecGrepForWorkspace;
// (web search removed)
this.useRipgrep = params.useRipgrep ?? true;
@@ -5143,6 +5151,18 @@ export class Config {
return this.disabledSkillNamesProvider?.() ?? EMPTY_DISABLED_SKILL_NAMES;
}
+ isZvecGrepEnabled(): boolean {
+ return this.zvecGrepEnabled;
+ }
+
+ canDisableZvecGrepForWorkspace(): boolean {
+ return this.onDisableZvecGrepForWorkspaceCallback !== undefined;
+ }
+
+ async disableZvecGrepForWorkspace(): Promise {
+ await this.onDisableZvecGrepForWorkspaceCallback?.();
+ }
+
/**
* Returns skill discovery levels excluded through
* `settings.skills.disabledLevels`.
@@ -8009,6 +8029,13 @@ export class Config {
return new ZoomImageTool(this);
});
+ if (this.isZvecGrepEnabled()) {
+ await registerLazy(ToolNames.ZVEC_GREP, async () => {
+ const { ZvecGrepTool } = await import('../tools/zvec-grep.js');
+ return new ZvecGrepTool(this);
+ });
+ }
+
// --- Grep / RipGrep (conditional) ---
if (this.getUseRipgrep()) {
let useRipgrep = false;
diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts
index 7b42b2ac737..74aaf672faa 100644
--- a/packages/core/src/core/coreToolScheduler.test.ts
+++ b/packages/core/src/core/coreToolScheduler.test.ts
@@ -16806,6 +16806,19 @@ describe('extractToolFilePaths', () => {
).toEqual(['packages/core', 'packages/core/**/*.ts']);
});
+ it('extracts zvec_grep paths and globs as path-shaped file filters', () => {
+ expect(
+ extractToolFilePaths('zvec_grep', {
+ operation: 'rg',
+ query: 'validate',
+ path: 'packages/core',
+ paths: ['src', 'include'],
+ glob: '**/*.{h,cc}',
+ exclude: ['thirdparty/**'],
+ }),
+ ).toEqual(['packages/core', 'src', 'include', 'packages/core/**/*.{h,cc}']);
+ });
+
it('decodes file:// URIs for lsp via fileURLToPath', () => {
// Regression: LSP `filePath` is allowed to be a `file://` URI.
// Forwarding the URI as-is to the activation registry would never
diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts
index 1d2702cc47e..1437c3fb9b9 100644
--- a/packages/core/src/core/coreToolScheduler.ts
+++ b/packages/core/src/core/coreToolScheduler.ts
@@ -531,6 +531,7 @@ const FS_PATH_TOOL_NAMES: ReadonlySet = new Set([
ToolNames.EDIT,
ToolNames.WRITE_FILE,
ToolNames.GREP,
+ ToolNames.ZVEC_GREP,
ToolNames.GLOB,
ToolNames.LS,
ToolNames.LSP,
@@ -714,6 +715,27 @@ export function extractToolFilePaths(
return out;
}
+ case ToolNames.ZVEC_GREP: {
+ const pathField = obj['path'];
+ const pathsField = obj['paths'];
+ const globField = obj['glob'];
+ push(pathField);
+ if (Array.isArray(pathsField)) {
+ for (const item of pathsField) {
+ push(item);
+ }
+ }
+ if (typeof globField === 'string' && globField.length > 0) {
+ push(
+ joinSearchRootAndGlob(
+ typeof pathField === 'string' ? pathField : undefined,
+ globField,
+ ),
+ );
+ }
+ return out;
+ }
+
case ToolNames.LS:
push(obj['path']);
return out;
diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts
index 15964b89746..5c6b632bf18 100644
--- a/packages/core/src/permissions/permission-manager.test.ts
+++ b/packages/core/src/permissions/permission-manager.test.ts
@@ -47,6 +47,8 @@ describe('resolveToolName', () => {
expect(resolveToolName('NotebookEdit')).toBe('notebook_edit');
expect(resolveToolName('NotebookEditTool')).toBe('notebook_edit');
expect(resolveToolName('WriteFileTool')).toBe('write_file');
+ expect(resolveToolName('ZvecGrep')).toBe('zvec_grep');
+ expect(resolveToolName('ZvecGrepTool')).toBe('zvec_grep');
});
it('resolves "Read" and "Edit" meta-categories', async () => {
@@ -97,6 +99,7 @@ describe('getSpecifierKind', () => {
expect(getSpecifierKind('notebook_edit')).toBe('path');
expect(getSpecifierKind('write_file')).toBe('path');
expect(getSpecifierKind('grep_search')).toBe('path');
+ expect(getSpecifierKind('zvec_grep')).toBe('path');
expect(getSpecifierKind('glob')).toBe('path');
expect(getSpecifierKind('list_directory')).toBe('path');
});
@@ -123,6 +126,7 @@ describe('toolMatchesRuleToolName', () => {
it('"Read" (read_file) covers all read-only file tools', async () => {
expect(toolMatchesRuleToolName('read_file', 'zoom_image')).toBe(true);
expect(toolMatchesRuleToolName('read_file', 'grep_search')).toBe(true);
+ expect(toolMatchesRuleToolName('read_file', 'zvec_grep')).toBe(true);
expect(toolMatchesRuleToolName('read_file', 'glob')).toBe(true);
expect(toolMatchesRuleToolName('read_file', 'list_directory')).toBe(true);
});
@@ -1084,10 +1088,11 @@ describe('matchesRule', () => {
});
// Meta-category matching: Read
- it('Read rule matches grep_search, glob, list_directory', async () => {
+ it('Read rule matches search, glob, and list tools', async () => {
const rule = parseRule('Read');
expect(matchesRule(rule, 'read_file')).toBe(true);
expect(matchesRule(rule, 'grep_search')).toBe(true);
+ expect(matchesRule(rule, 'zvec_grep')).toBe(true);
expect(matchesRule(rule, 'glob')).toBe(true);
expect(matchesRule(rule, 'list_directory')).toBe(true);
expect(matchesRule(rule, 'edit')).toBe(false); // not a read tool
@@ -2382,6 +2387,15 @@ describe('PermissionManager', () => {
).toBe('deny');
});
+ it('Read deny applies to zvec_grep too (meta-category)', async () => {
+ expect(
+ await pm.evaluate({
+ toolName: 'zvec_grep',
+ filePath: '/project/.env',
+ }),
+ ).toBe('deny');
+ });
+
it('returns default for unmatched path', async () => {
expect(
await pm.evaluate({
@@ -2775,6 +2789,7 @@ describe('getRuleDisplayName', () => {
expect(getRuleDisplayName('read_file')).toBe('Read');
expect(getRuleDisplayName('zoom_image')).toBe('Read');
expect(getRuleDisplayName('grep_search')).toBe('Read');
+ expect(getRuleDisplayName('zvec_grep')).toBe('Read');
expect(getRuleDisplayName('glob')).toBe('Read');
expect(getRuleDisplayName('list_directory')).toBe('Read');
});
@@ -2833,6 +2848,14 @@ describe('buildPermissionRules', () => {
expect(rules).toEqual(['Read(//external/dir/**)']);
});
+ it('generates Read rule with directory as-is for zvec_grep', async () => {
+ const rules = buildPermissionRules({
+ toolName: 'zvec_grep',
+ filePath: '/external/dir',
+ });
+ expect(rules).toEqual(['Read(//external/dir/**)']);
+ });
+
it('generates Read rule with directory as-is for glob', async () => {
const rules = buildPermissionRules({
toolName: 'glob',
diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts
index 66847e42cff..1897fd8247d 100644
--- a/packages/core/src/permissions/rule-parser.ts
+++ b/packages/core/src/permissions/rule-parser.ts
@@ -87,6 +87,11 @@ export const TOOL_NAME_ALIASES: Readonly> = {
search_file_content: 'grep_search', // legacy
SearchFiles: 'grep_search', // legacy display name
+ // Zvec Grep tool — also matched by "Read" meta-category rules
+ zvec_grep: 'zvec_grep',
+ ZvecGrep: 'zvec_grep',
+ ZvecGrepTool: 'zvec_grep',
+
// Glob tool — also matched by "Read" meta-category rules
glob: 'glob',
Glob: 'glob',
@@ -178,6 +183,7 @@ const READ_TOOLS = new Set([
'read_file',
'zoom_image',
'grep_search',
+ 'zvec_grep',
'glob',
'list_directory',
]);
@@ -409,6 +415,7 @@ const CANONICAL_TO_RULE_DISPLAY: Readonly> = {
read_file: 'Read',
zoom_image: 'Read',
grep_search: 'Read',
+ zvec_grep: 'Read',
glob: 'Read',
list_directory: 'Read',
// Edit meta-category
diff --git a/packages/core/src/subagents/builtin-agents.test.ts b/packages/core/src/subagents/builtin-agents.test.ts
index 69485029501..422c5c60171 100644
--- a/packages/core/src/subagents/builtin-agents.test.ts
+++ b/packages/core/src/subagents/builtin-agents.test.ts
@@ -7,6 +7,7 @@
import { describe, it, expect } from 'vitest';
import { ToolNames } from '../tools/tool-names.js';
import { BuiltinAgentRegistry } from './builtin-agents.js';
+import { ToolNames } from '../tools/tool-names.js';
describe('BuiltinAgentRegistry', () => {
describe('getBuiltinAgents', () => {
@@ -60,6 +61,12 @@ describe('BuiltinAgentRegistry', () => {
expect(exploreAgent?.model).toBeUndefined();
});
+ it('should allow Explore to use zvec-grep when the tool is enabled', () => {
+ const agent = BuiltinAgentRegistry.getBuiltinAgent('Explore');
+
+ expect(agent?.tools).toContain(ToolNames.ZVEC_GREP);
+ });
+
it('keeps the Explore agent read-only without banning shell pipelines', () => {
const exploreAgent = BuiltinAgentRegistry.getBuiltinAgent('Explore');
diff --git a/packages/core/src/subagents/builtin-agents.ts b/packages/core/src/subagents/builtin-agents.ts
index 8be015325f5..5f7e5248902 100644
--- a/packages/core/src/subagents/builtin-agents.ts
+++ b/packages/core/src/subagents/builtin-agents.ts
@@ -91,6 +91,7 @@ Notes:
tools: [
ToolNames.READ_FILE,
ToolNames.GREP,
+ ToolNames.ZVEC_GREP,
ToolNames.GLOB,
ToolNames.SHELL,
ToolNames.LS,
diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts
index f79a82cd604..af7e28f9752 100644
--- a/packages/core/src/subagents/subagent-manager.test.ts
+++ b/packages/core/src/subagents/subagent-manager.test.ts
@@ -2109,6 +2109,19 @@ bad`);
]);
});
+ it('should omit zvec_grep when the optional tool is not registered', async () => {
+ const configWithOptionalSearch: SubagentConfig = {
+ ...validConfig,
+ tools: ['read_file', 'zvec_grep'],
+ };
+
+ const runtimeConfig = await manager.convertToRuntimeConfig(
+ configWithOptionalSearch,
+ );
+
+ expect(runtimeConfig.toolConfig?.tools).toEqual(['read_file']);
+ });
+
it('fails closed when the allow-list is only the unavailable WebSearch', async () => {
// The unresolved name stays a dead, restrictive entry: the agent
// runs tool-less rather than inheriting shell/write it was not
diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts
index 810b00895da..90951adc38e 100644
--- a/packages/core/src/subagents/subagent-manager.ts
+++ b/packages/core/src/subagents/subagent-manager.ts
@@ -1258,6 +1258,10 @@ export class SubagentManager {
continue;
}
+ if (toolIdentifier === ToolNames.ZVEC_GREP) {
+ continue;
+ }
+
// If no match found, preserve the original identifier as-is
// This allows for tools that might not be registered yet or custom tools
result.push(toolIdentifier);
diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts
index df17f0956ac..ef21cf25530 100644
--- a/packages/core/src/tools/tool-names.ts
+++ b/packages/core/src/tools/tool-names.ts
@@ -23,6 +23,7 @@ export const ToolNames = {
READ_FILE: 'read_file',
ZOOM_IMAGE: 'zoom_image',
GREP: 'grep_search',
+ ZVEC_GREP: 'zvec_grep',
GLOB: 'glob',
SHELL: 'run_shell_command',
TODO_WRITE: 'todo_write',
@@ -83,6 +84,7 @@ export const ToolDisplayNames = {
READ_FILE: 'ReadFile',
ZOOM_IMAGE: 'ZoomImage',
GREP: 'Grep',
+ ZVEC_GREP: 'ZvecGrep',
GLOB: 'Glob',
SHELL: 'Shell',
TODO_WRITE: 'TodoList',
diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts
index e98c50a3c87..b56a4c63e21 100644
--- a/packages/core/src/tools/tools.ts
+++ b/packages/core/src/tools/tools.ts
@@ -963,6 +963,8 @@ export interface ToolAskUserQuestionConfirmationDetails {
description: string;
}>;
multiSelect?: boolean;
+ /** Whether to append the free-form "Type something" choice. */
+ allowCustomInput?: boolean;
}>;
metadata?: {
source?: string;
diff --git a/packages/core/src/tools/zvec-grep.test.ts b/packages/core/src/tools/zvec-grep.test.ts
new file mode 100644
index 00000000000..b6eed57486f
--- /dev/null
+++ b/packages/core/src/tools/zvec-grep.test.ts
@@ -0,0 +1,1725 @@
+/**
+ * @license
+ * Copyright 2025 Qwen
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { EventEmitter } from 'node:events';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { Config } from '../config/config.js';
+import { FileReadCache } from '../services/fileReadCache.js';
+import { runRipgrep } from '../utils/ripgrepUtils.js';
+import { ToolConfirmationOutcome } from './tools.js';
+import { _resetZvecGrepInstallForTest, ZvecGrepTool } from './zvec-grep.js';
+
+vi.mock('node:child_process', () => ({
+ spawn: vi.fn(),
+}));
+vi.mock('../utils/ripgrepUtils.js', () => ({
+ runRipgrep: vi.fn(),
+}));
+
+const spawnMock = vi.mocked(spawn);
+const runRipgrepMock = vi.mocked(runRipgrep);
+
+const UNINDEXED_STATUS = [
+ 'root\t/tmp/workspace',
+ 'policy\tundecided',
+ 'indexed\tno',
+ 'state\tundecided',
+ 'source\tunindexed',
+].join('\n');
+const ENABLE_WORKSPACE_CHOICE = 'Enable for this workspace';
+const NOT_THIS_SESSION_CHOICE = 'Not this session';
+const DISABLE_WORKSPACE_CHOICE = 'Disable for this workspace';
+
+type QueuedSpawnResult = {
+ stdout?: string;
+ stderr?: string;
+ code?: number;
+ error?: NodeJS.ErrnoException;
+};
+
+type MockChild = EventEmitter & {
+ stdout: EventEmitter;
+ stderr: EventEmitter;
+ kill: ReturnType;
+ pid: number;
+ unref: ReturnType;
+};
+
+const tempRoots: string[] = [];
+const API_ENV_NAMES = [
+ 'ZVEC_GREP_API_KEY',
+ 'DASHSCOPE_API_KEY',
+ 'QWEN_API_KEY',
+ 'ZVEC_GREP_EMBEDDING',
+] as const;
+const originalApiEnv = Object.fromEntries(
+ API_ENV_NAMES.map((name) => [name, process.env[name]]),
+) as Record<(typeof API_ENV_NAMES)[number], string | undefined>;
+
+function createTempRoot(): string {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zvec-grep-tool-test-'));
+ tempRoots.push(root);
+ return root;
+}
+
+function getWorkspaceJobKey(root: string): string {
+ return crypto.createHash('sha1').update(root).digest('hex').slice(0, 16);
+}
+
+function listWorkspaceJobFiles(root: string): string[] {
+ const jobDir = path.join(os.tmpdir(), 'qwen-zvec-grep-index');
+ if (!fs.existsSync(jobDir)) return [];
+ const jobKey = getWorkspaceJobKey(root);
+ return fs
+ .readdirSync(jobDir)
+ .filter((name) => name.startsWith(jobKey))
+ .map((name) => path.join(jobDir, name));
+}
+
+function removeWorkspaceJobFiles(root: string): void {
+ for (const filePath of listWorkspaceJobFiles(root)) {
+ fs.rmSync(filePath, { force: true });
+ }
+}
+
+function createTool(
+ root: string,
+ interactive = true,
+ fileReadCache = new FileReadCache(),
+ disableForWorkspace: () => Promise = async () => {},
+): ZvecGrepTool {
+ return new ZvecGrepTool({
+ getTargetDir: () => root,
+ isInteractive: () => interactive,
+ getWorkspaceContext: () => ({
+ isPathWithinWorkspace: (filePath: string) =>
+ filePath === root || filePath.startsWith(`${root}${path.sep}`),
+ }),
+ getFileReadCache: () => fileReadCache,
+ getFileReadCacheDisabled: () => false,
+ getUseBuiltinRipgrep: () => true,
+ canDisableZvecGrepForWorkspace: () => true,
+ disableZvecGrepForWorkspace: disableForWorkspace,
+ } as unknown as Config);
+}
+
+function createMockChild(): MockChild {
+ const child = new EventEmitter() as MockChild;
+ child.stdout = new EventEmitter();
+ child.stderr = new EventEmitter();
+ child.kill = vi.fn();
+ child.pid = 12345;
+ child.unref = vi.fn();
+ return child;
+}
+
+function queueSpawnResult(result: QueuedSpawnResult): void {
+ spawnMock.mockImplementationOnce((() => {
+ const child = createMockChild();
+
+ process.nextTick(() => {
+ if (result.error) {
+ child.emit('error', result.error);
+ return;
+ }
+ if (result.stdout) {
+ child.stdout.emit('data', Buffer.from(result.stdout));
+ }
+ if (result.stderr) {
+ child.stderr.emit('data', Buffer.from(result.stderr));
+ }
+ child.emit('exit', result.code ?? 0);
+ child.emit('close', result.code ?? 0);
+ });
+
+ return child;
+ }) as unknown as typeof spawn);
+}
+
+function queueSpawnChild(child: MockChild): void {
+ spawnMock.mockImplementationOnce((() => child) as unknown as typeof spawn);
+}
+
+function queueRipgrepResult(
+ stdout: string,
+ options: { truncated?: boolean; error?: Error } = {},
+): void {
+ runRipgrepMock.mockResolvedValueOnce({
+ stdout,
+ truncated: options.truncated ?? false,
+ error: options.error,
+ });
+}
+
+async function chooseSetup(
+ invocation: ReturnType,
+ choice: string,
+) {
+ const details = await invocation.getConfirmationDetails(
+ new AbortController().signal,
+ );
+ if (details.type !== 'ask_user_question') {
+ throw new Error('expected setup question');
+ }
+ await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
+ answers: { 0: choice },
+ });
+ return details;
+}
+
+function setFakeRemoteEmbeddingKey(): void {
+ process.env['DASHSCOPE_API_KEY'] = 'test-api-key';
+}
+
+function clearEmbeddingEnv(): void {
+ for (const name of API_ENV_NAMES) {
+ delete process.env[name];
+ }
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ _resetZvecGrepInstallForTest();
+ spawnMock.mockReset();
+ runRipgrepMock.mockReset();
+ for (const name of API_ENV_NAMES) {
+ const value = originalApiEnv[name];
+ if (value === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = value;
+ }
+ }
+ for (const root of tempRoots.splice(0)) {
+ removeWorkspaceJobFiles(root);
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+describe('ZvecGrepTool', () => {
+ it('exposes a compact search-oriented schema', () => {
+ const tool = createTool(createTempRoot());
+ const schema = tool.schema.parametersJsonSchema as {
+ properties: Record;
+ required: string[];
+ };
+
+ expect(Object.keys(schema.properties).sort()).toEqual([
+ 'exclude',
+ 'glob',
+ 'limit',
+ 'operation',
+ 'path',
+ 'paths',
+ 'pattern',
+ 'query',
+ ]);
+ expect(schema.required).toEqual(['operation']);
+ expect(schema.properties).not.toHaveProperty('include');
+ expect(schema.properties).not.toHaveProperty('embedding');
+ expect(schema.properties).not.toHaveProperty('background');
+ expect(schema.properties['operation']?.enum).toEqual(['semantic', 'rg']);
+ expect(schema.properties['operation']?.enum).toContain('semantic');
+ expect(schema.properties['operation']?.enum).toContain('rg');
+ expect(schema.properties['operation']?.enum).not.toContain('index');
+ expect(schema.properties['operation']?.enum).not.toContain('disable_index');
+ expect(schema.properties['operation']?.enum).not.toContain('status');
+ expect(schema.properties['operation']?.enum).not.toContain('grep');
+ expect(schema.properties['query']?.description).toContain(
+ 'operation="semantic"',
+ );
+ expect(schema.properties['pattern']?.description).toContain(
+ 'operation="rg"',
+ );
+ });
+
+ it('describes zvec-grep without hidden operations', () => {
+ const tool = createTool(createTempRoot());
+
+ expect(tool.description).toContain('semantic discovery');
+ expect(tool.description).toContain('ripgrep-compatible matching');
+ expect(tool.description).toContain('operation="semantic" with query');
+ expect(tool.description).toContain('semantic or fuzzy discovery');
+ expect(tool.description).toContain('operation="rg" with pattern');
+ expect(tool.description).toContain('regular-expression searches');
+ expect(tool.description).toContain('candidates to inspect');
+ expect(tool.description).toContain('line ranges');
+ expect(tool.description).toContain('increase limit');
+ expect(tool.description).not.toContain('operation="index"');
+ expect(tool.description).not.toContain('operation="status"');
+ expect(tool.description).not.toContain('transparently falls back');
+ expect(tool.description).not.toContain('installation/indexing');
+ expect(tool.description).not.toContain('zvec_grep_semantic_fallback_rg');
+ expect(tool.description).not.toContain('do not use grep_search');
+ });
+
+ it('validates the public zvec-grep parameter contract', () => {
+ const tool = createTool(createTempRoot());
+ const validate = (params: unknown) =>
+ tool.validateToolParams(
+ params as Parameters[0],
+ );
+
+ expect(validate({ operation: 'invalid', query: 'auth flow' })).toContain(
+ 'operation',
+ );
+ expect(validate({ operation: 'semantic' })).toBe(
+ 'query or pattern must be a non-empty string for operation="semantic"',
+ );
+ expect(
+ validate({ operation: 'semantic', query: ' ', pattern: 'auth' }),
+ ).toBe('query must be a non-empty string when provided');
+ expect(validate({ operation: 'rg', query: 'auth', pattern: ' ' })).toBe(
+ 'pattern must be a non-empty string when provided',
+ );
+ expect(validate({ operation: 'rg', query: 'auth', limit: 1.5 })).toContain(
+ 'integer',
+ );
+ expect(validate({ operation: 'rg', query: 'auth', limit: -1 })).toContain(
+ '>= 1',
+ );
+ expect(validate({ operation: 'rg', query: 'auth', path: ' ' })).toBe(
+ 'path must be a non-empty string when provided',
+ );
+ expect(validate({ operation: 'rg', query: 'auth', glob: ' ' })).toBe(
+ 'glob must be a non-empty string when provided',
+ );
+ expect(
+ validate({ operation: 'rg', query: 'auth', paths: ['src', ''] }),
+ ).toBe('paths must contain only non-empty strings');
+ expect(
+ validate({ operation: 'rg', query: 'auth', exclude: ['dist/**', ''] }),
+ ).toBe('exclude must contain only non-empty strings');
+ expect(
+ validate({ operation: 'rg', query: 'auth', paths: ['src', 42] }),
+ ).toBe('paths must contain only non-empty strings');
+ expect(
+ validate({ operation: 'rg', query: 'auth', exclude: ['dist/**', 42] }),
+ ).toBe('exclude must contain only non-empty strings');
+ });
+
+ it('asks before setup, then indexes in the background after approval', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({});
+ queueSpawnResult({
+ stdout: 'src/index.ts:1\n 1 // vector index metadata storage\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'vector index metadata storage',
+ });
+
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ const details = await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+ expect(details.questions[0]?.allowCustomInput).toBe(false);
+ expect(details.questions[0]?.question).toContain(
+ 'build a semantic index for this workspace',
+ );
+ expect(details.questions[0]?.question).toContain(
+ 'workspace code fragments and semantic search queries are sent to the Qwen/DashScope embedding service',
+ );
+ expect(details.questions[0]?.options.map((option) => option.label)).toEqual(
+ [
+ ENABLE_WORKSPACE_CHOICE,
+ NOT_THIS_SESSION_CHOICE,
+ DISABLE_WORKSPACE_CHOICE,
+ ],
+ );
+ const result = await invocation.execute(new AbortController().signal);
+ const content = String(result.llmContent);
+
+ expect(content).toContain('src/index.ts:1');
+ expect(content).not.toContain('zvec_grep_semantic_fallback_rg');
+ expect(content).not.toContain('fallback_reason');
+ expect(content).not.toContain('semantic_search: unavailable');
+ expect(content).not.toContain('zvec_grep_index_required');
+ expect(result.returnDisplay).toContain(
+ 'Semantic indexing is running in the background',
+ );
+ expect(content).not.toContain(
+ 'Semantic indexing is running in the background',
+ );
+ expect(spawnMock).toHaveBeenCalledTimes(4);
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[2]?.[1]).toEqual([
+ 'index',
+ '--embedding',
+ 'qwen/text-embedding-v4',
+ ]);
+ expect(spawnMock.mock.calls[3]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)(vector|index|metadata|storage)',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it('uses a lexical rg pattern in non-interactive unindexed workspaces', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ fs.mkdirSync(path.join(root, 'src'));
+ fs.mkdirSync(path.join(root, 'docs'));
+ fs.mkdirSync(path.join(root, 'thirdparty'));
+ fs.writeFileSync(path.join(root, 'src', 'index.ts'), 'export const x = 1;');
+ fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# docs');
+ fs.writeFileSync(
+ path.join(root, 'thirdparty', 'vendor.cc'),
+ 'int main() {}',
+ );
+
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({
+ stdout: 'docs/README.md:1\n 1 # index types supported\n',
+ });
+ const invocation = createTool(root, false).build({
+ operation: 'semantic',
+ query: 'index types supported',
+ });
+
+ await expect(invocation.getDefaultPermission()).resolves.toBe('allow');
+ const result = await invocation.execute(new AbortController().signal);
+ const content = String(result.llmContent);
+
+ expect(content).toContain('docs/README.md:1');
+ expect(content).not.toContain('zvec_grep_semantic_fallback_rg');
+ expect(content).not.toContain('fallback_reason');
+ expect(content).not.toContain('semantic_search: unavailable');
+ expect(content).not.toContain('zvec_grep_index_required');
+ expect(content).not.toContain('grep_search');
+ expect(spawnMock).toHaveBeenCalledTimes(2);
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)(index|types)',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it('does not build an index for semantic search without an embedding api key', async () => {
+ clearEmbeddingEnv();
+ const root = createTempRoot();
+ const staleLocalStatus = `${UNINDEXED_STATUS}\nembedding\tlocal/old-model`;
+ queueSpawnResult({ stdout: staleLocalStatus });
+ queueSpawnResult({ stdout: staleLocalStatus });
+ queueSpawnResult({
+ stdout: 'src/index.ts:1\n 1 // vector index metadata storage\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'vector index metadata storage',
+ });
+
+ await expect(invocation.getDefaultPermission()).resolves.toBe('allow');
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/index.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(3);
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[2]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)(vector|index|metadata|storage)',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it('falls back to rg for ready remote indexes without an embedding api key', async () => {
+ clearEmbeddingEnv();
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: [
+ 'root\t/tmp/workspace',
+ 'policy\tindexed',
+ 'indexed\tyes',
+ 'embedding\tqwen/text-embedding-v4',
+ ].join('\n'),
+ });
+ queueSpawnResult({
+ stdout: 'src/auth.ts:1\n 1 authentication flow\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/auth.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(2);
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)(authentication|flow)',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it('runs semantic search when a remote index is ready and an api key is set', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: [
+ 'root\t/tmp/workspace',
+ 'policy\tindexed',
+ 'indexed\tyes',
+ 'embedding\tqwen/text-embedding-v4',
+ ].join('\n'),
+ });
+ queueSpawnResult({
+ stdout: 'src/auth.ts:1\n 1 authentication flow\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ limit: 5,
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/auth.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(2);
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--limit',
+ '5',
+ '--',
+ 'authentication flow',
+ ]);
+ });
+
+ it('silently falls back to rg when a semantic query fails', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: [
+ 'root\t/tmp/workspace',
+ 'policy\tindexed',
+ 'indexed\tyes',
+ 'state\tready',
+ 'embedding\tqwen/text-embedding-v4',
+ ].join('\n'),
+ });
+ queueSpawnResult({ code: 1, stderr: 'embedding service unavailable\n' });
+ queueSpawnResult({
+ stdout: 'src/auth.ts:1\n 1 authentication flow\n',
+ });
+
+ const result = await createTool(root)
+ .build({ operation: 'semantic', query: 'authentication flow' })
+ .execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/auth.ts:1');
+ expect(String(result.llmContent)).not.toContain(
+ 'embedding service unavailable',
+ );
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--limit',
+ '20',
+ '--',
+ 'authentication flow',
+ ]);
+ expect(spawnMock.mock.calls[2]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)(authentication|flow)',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it('does not prompt or index when the workspace policy is disabled', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ const disabledStatus = [
+ 'root\t/tmp/workspace',
+ 'policy\tdisabled',
+ 'indexed\tno',
+ 'state\tundecided',
+ 'source\tunindexed',
+ ].join('\n');
+ queueSpawnResult({ stdout: disabledStatus });
+ queueSpawnResult({ stdout: disabledStatus });
+ queueSpawnResult({
+ stdout: 'src/auth.ts:1\n 1 authentication flow\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+
+ await expect(invocation.getDefaultPermission()).resolves.toBe('allow');
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/auth.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(3);
+ expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === 'index')).toBe(
+ false,
+ );
+ });
+
+ it('passes option-like queries as data instead of zg flags', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: [
+ 'root\t/tmp/workspace',
+ 'policy\tindexed',
+ 'indexed\tyes',
+ 'embedding\tqwen/text-embedding-v4',
+ ].join('\n'),
+ });
+ queueSpawnResult({});
+ queueSpawnResult({});
+ const tool = createTool(root);
+
+ await tool
+ .build({ operation: 'semantic', query: '--status' })
+ .execute(new AbortController().signal);
+ await tool
+ .build({ operation: 'rg', pattern: '--index', path: '--status' })
+ .execute(new AbortController().signal);
+
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--limit',
+ '20',
+ '--',
+ '--status',
+ ]);
+ expect(spawnMock.mock.calls[2]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '--index',
+ '--',
+ '--status',
+ ]);
+ });
+
+ it('intersects semantic path scopes with the requested glob', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: [
+ 'root\t/tmp/workspace',
+ 'policy\tindexed',
+ 'indexed\tyes',
+ 'embedding\tqwen/text-embedding-v4',
+ ].join('\n'),
+ });
+ queueSpawnResult({});
+
+ await createTool(root)
+ .build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ paths: ['src', 'packages'],
+ glob: '**/*.{ts,tsx}',
+ exclude: ['dist/**', 'vendor/**'],
+ })
+ .execute(new AbortController().signal);
+
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--limit',
+ '20',
+ '--glob',
+ 'src/**/*.ts',
+ '--glob',
+ 'src/**/*.tsx',
+ '--glob',
+ 'packages/**/*.ts',
+ '--glob',
+ 'packages/**/*.tsx',
+ '--glob',
+ '!dist/**',
+ '--glob',
+ '!vendor/**',
+ '--',
+ 'authentication flow',
+ ]);
+ });
+
+ it.each([
+ ['src/auth.ts', '**/*.ts', ['src/auth.ts', 'src/auth.ts/**/*.ts']],
+ ['src/auth.js', '**/*.ts', ['src/auth.js/**/*.ts']],
+ ['.github', '**/*.yml', ['.github/**/*.yml']],
+ ['LICENSE', '*', ['LICENSE', 'LICENSE/**/*']],
+ ])(
+ 'intersects semantic scope %s with glob %s',
+ async (scope, glob, expectedGlobs) => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: [
+ 'root\t/tmp/workspace',
+ 'policy\tindexed',
+ 'indexed\tyes',
+ 'state\tready',
+ 'embedding\tqwen/text-embedding-v4',
+ ].join('\n'),
+ });
+ queueSpawnResult({});
+
+ await createTool(root)
+ .build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ path: scope,
+ glob,
+ })
+ .execute(new AbortController().signal);
+
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--limit',
+ '20',
+ ...expectedGlobs.flatMap((expectedGlob) => ['--glob', expectedGlob]),
+ '--',
+ 'authentication flow',
+ ]);
+ },
+ );
+
+ it.each([
+ ['.github', ['.github', '.github/**']],
+ ['LICENSE', ['LICENSE', 'LICENSE/**']],
+ ['packages/*/src', ['packages/*/src']],
+ ])(
+ 'preserves exact and descendant matches for semantic scope %s',
+ async (scope, expectedGlobs) => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: [
+ 'root\t/tmp/workspace',
+ 'policy\tindexed',
+ 'indexed\tyes',
+ 'state\tready',
+ 'embedding\tqwen/text-embedding-v4',
+ ].join('\n'),
+ });
+ queueSpawnResult({});
+
+ await createTool(root)
+ .build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ path: scope,
+ })
+ .execute(new AbortController().signal);
+
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--limit',
+ '20',
+ ...expectedGlobs.flatMap((expectedGlob) => ['--glob', expectedGlob]),
+ '--',
+ 'authentication flow',
+ ]);
+ },
+ );
+
+ it('uses the default semantic limit when none is provided', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: [
+ 'root\t/tmp/workspace',
+ 'policy\tindexed',
+ 'indexed\tyes',
+ 'embedding\tqwen/text-embedding-v4',
+ ].join('\n'),
+ });
+ queueSpawnResult({
+ stdout: 'src/auth.ts:1\n 1 authentication flow\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/auth.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(2);
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--limit',
+ '20',
+ '--',
+ 'authentication flow',
+ ]);
+ });
+
+ it('uses a configured local embedding without an api key', async () => {
+ clearEmbeddingEnv();
+ process.env['ZVEC_GREP_EMBEDDING'] = 'local/embeddinggemma-300m';
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({});
+ queueSpawnResult({
+ stdout: 'src/index.ts:1\n 1 // vector index metadata storage\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'vector index metadata storage',
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ const details = await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+ expect(details.questions[0]?.question).toContain(
+ 'workspace files stay local',
+ );
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/index.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(4);
+ expect(spawnMock.mock.calls[2]?.[1]).toEqual([
+ 'index',
+ '--embedding',
+ 'local/embeddinggemma-300m',
+ ]);
+ expect(spawnMock.mock.calls[3]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)(vector|index|metadata|storage)',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it('identifies a configured remote embedding in the setup question', async () => {
+ setFakeRemoteEmbeddingKey();
+ process.env['ZVEC_GREP_EMBEDDING'] = 'qwen/custom-embedding';
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: `${UNINDEXED_STATUS}\nembedding\tlocal/old-model`,
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ const details = await invocation.getConfirmationDetails(
+ new AbortController().signal,
+ );
+
+ expect(details.type).toBe('ask_user_question');
+ if (details.type === 'ask_user_question') {
+ expect(details.questions[0]?.question).toContain(
+ 'configured qwen/custom-embedding remote embedding model',
+ );
+ }
+ });
+
+ it('prefers code-like terms for semantic rg fallback', async () => {
+ clearEmbeddingEnv();
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({
+ stdout: 'src/common/index/fts/fts_query_ast.h:1\n 1 FTS query AST\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'full text search FTS implementation',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('fts_query_ast.h:1');
+ expect(spawnMock).toHaveBeenCalledTimes(2);
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)FTS',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it('drops task framing words from semantic rg fallback', async () => {
+ clearEmbeddingEnv();
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({
+ stdout: 'packages/core/src/tools/zvec-grep.ts:1\n 1 install\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'please show where current auto install handling is implemented',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('zvec-grep.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(2);
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)(auto|install)',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it('does not pass semantic fallback queries as exact phrases', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({
+ stdout: 'src/streamer/stream_service.cc:1\n 1 write request flow\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'streamer write flow',
+ });
+
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('stream_service.cc:1');
+ expect(String(result.llmContent)).not.toContain('fallback_reason');
+ expect(spawnMock).toHaveBeenCalledTimes(2);
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)(streamer|write|flow)',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it.each([
+ ['a(', '(?i)a\\('],
+ ['C++', '(?i)C\\+\\+'],
+ ])(
+ 'escapes regex-only semantic fallback query %s',
+ async (query, pattern) => {
+ clearEmbeddingEnv();
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({});
+
+ await createTool(root)
+ .build({ operation: 'semantic', query })
+ .execute(new AbortController().signal);
+
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ pattern,
+ '--limit',
+ '20',
+ ]);
+ },
+ );
+
+ it('uses native rg for the rest of the session after Not this session', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueRipgrepResult(`${path.join(root, 'src/a.ts')}:1:authentication\n`);
+ queueRipgrepResult(`${path.join(root, 'src/b.ts')}:2:authorization\n`);
+ const tool = createTool(root);
+ const firstInvocation = tool.build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+
+ await expect(firstInvocation.getDefaultPermission()).resolves.toBe('ask');
+ await chooseSetup(firstInvocation, NOT_THIS_SESSION_CHOICE);
+ const firstResult = await firstInvocation.execute(
+ new AbortController().signal,
+ );
+
+ const secondInvocation = tool.build({
+ operation: 'semantic',
+ query: 'authorization flow',
+ });
+ await expect(secondInvocation.getDefaultPermission()).resolves.toBe(
+ 'allow',
+ );
+ const secondResult = await secondInvocation.execute(
+ new AbortController().signal,
+ );
+
+ expect(String(firstResult.llmContent)).toContain('src/a.ts:1');
+ expect(String(secondResult.llmContent)).toContain('src/b.ts:2');
+ expect(spawnMock).toHaveBeenCalledTimes(1);
+ expect(runRipgrepMock).toHaveBeenCalledTimes(2);
+ expect(runRipgrepMock.mock.calls[0]?.[0]).toContain('-e');
+ expect(runRipgrepMock.mock.calls[0]?.[0]).toContain(
+ '(?i)(authentication|flow)',
+ );
+ });
+
+ it('persists Disable for this workspace and uses native rg immediately', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ const disableForWorkspace = vi.fn(async () => {});
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueRipgrepResult(`${path.join(root, 'src/a.ts')}:1:authentication\n`);
+ const invocation = createTool(
+ root,
+ true,
+ new FileReadCache(),
+ disableForWorkspace,
+ ).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ await chooseSetup(invocation, DISABLE_WORKSPACE_CHOICE);
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(disableForWorkspace).toHaveBeenCalledOnce();
+ expect(String(result.llmContent)).toContain('src/a.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(1);
+ expect(runRipgrepMock).toHaveBeenCalledOnce();
+ });
+
+ it('asks before searching paths outside the workspace', async () => {
+ const root = createTempRoot();
+ const externalRoot = createTempRoot();
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'secret',
+ paths: [externalRoot],
+ });
+
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ const details = await invocation.getConfirmationDetails(
+ new AbortController().signal,
+ );
+
+ expect(details.type).toBe('info');
+ if (details.type !== 'info') {
+ throw new Error('expected info confirmation');
+ }
+ expect(details.prompt).toContain('outside the current workspace');
+ expect(details.prompt).toContain(externalRoot);
+ expect(spawnMock).not.toHaveBeenCalled();
+ });
+
+ it('runs exact grep through zg query --rg without checking index status', async () => {
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout:
+ 'src/index.ts:1\n 1 export const validate = true;\nsrc/other.ts:3\n 3 validate();\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'validate',
+ glob: '**/*.ts',
+ paths: ['src'],
+ limit: 5,
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('allow');
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/index.ts:1');
+ expect(String(result.llmContent)).toContain('src/other.ts:3');
+ expect(result.returnDisplay).toBe('Found 2 matches');
+ expect(spawnMock).toHaveBeenCalledTimes(1);
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ 'validate',
+ '--limit',
+ '5',
+ '--glob',
+ '**/*.ts',
+ '--',
+ 'src',
+ ]);
+ });
+
+ it('uses native rg when the installed zg command is incompatible', async () => {
+ const root = createTempRoot();
+ queueSpawnResult({
+ code: 1,
+ stderr: 'Unknown command: query\n',
+ });
+ queueRipgrepResult(`${path.join(root, 'src/index.ts')}:1:validate\n`);
+
+ const result = await createTool(root)
+ .build({ operation: 'rg', query: 'validate' })
+ .execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/index.ts:1');
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ 'validate',
+ ]);
+ expect(runRipgrepMock).toHaveBeenCalledOnce();
+ });
+
+ it('uses the npm prefix itself as the global binary directory on Windows', async () => {
+ const root = createTempRoot();
+ const originalPrefix = process.env['npm_config_prefix'];
+ const originalPath = process.env['PATH'];
+ const platform = vi
+ .spyOn(process, 'platform', 'get')
+ .mockReturnValue('win32');
+ process.env['npm_config_prefix'] = 'C:\\npm-prefix';
+ process.env['PATH'] = 'C:\\Windows\\System32';
+ queueSpawnResult({});
+
+ try {
+ await createTool(root)
+ .build({ operation: 'rg', query: 'validate' })
+ .execute(new AbortController().signal);
+
+ const childPath = spawnMock.mock.calls[0]?.[2]?.env?.['PATH'];
+ expect(childPath?.split(';')[0]).toBe('C:\\npm-prefix');
+ expect(childPath).not.toContain('C:\\npm-prefix\\bin');
+ } finally {
+ platform.mockRestore();
+ if (originalPrefix === undefined) {
+ delete process.env['npm_config_prefix'];
+ } else {
+ process.env['npm_config_prefix'] = originalPrefix;
+ }
+ if (originalPath === undefined) {
+ delete process.env['PATH'];
+ } else {
+ process.env['PATH'] = originalPath;
+ }
+ }
+ });
+
+ it('uses native rg without installing when exact search cannot find zg', async () => {
+ const root = createTempRoot();
+ const error = Object.assign(new Error('spawn zg ENOENT'), {
+ code: 'ENOENT',
+ }) as NodeJS.ErrnoException;
+ queueSpawnResult({ error });
+ queueRipgrepResult(
+ [
+ `${path.join(root, 'src/index.ts')}:1:validate`,
+ `${path.join(root, 'src/other.tsx')}:2:validate`,
+ '',
+ ].join('\n'),
+ );
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'validate',
+ paths: ['src'],
+ glob: '*.{ts,tsx}',
+ exclude: ['dist/**'],
+ limit: 1,
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/index.ts:1');
+ expect(String(result.llmContent)).not.toContain('src/other.tsx:2');
+ expect(String(result.llmContent)).toContain('Output was truncated');
+ expect(spawnMock).toHaveBeenCalledTimes(1);
+ expect(spawnMock.mock.calls[0]?.[0]).toBe('zg');
+ expect(runRipgrepMock).toHaveBeenCalledOnce();
+ expect(runRipgrepMock.mock.calls[0]?.[0]).toEqual([
+ '--line-number',
+ '--with-filename',
+ '--no-heading',
+ '--color',
+ 'never',
+ '--glob',
+ '*.ts',
+ '--glob',
+ '*.tsx',
+ '--glob',
+ '!dist/**',
+ '-e',
+ 'validate',
+ '--',
+ path.join(root, 'src'),
+ ]);
+ });
+
+ it('returns a regular search error when native rg cannot start', async () => {
+ const root = createTempRoot();
+ const error = Object.assign(new Error('spawn zg ENOENT'), {
+ code: 'ENOENT',
+ }) as NodeJS.ErrnoException;
+ queueSpawnResult({ error });
+ runRipgrepMock.mockRejectedValueOnce(new Error('ripgrep not found'));
+
+ const result = await createTool(root)
+ .build({ operation: 'rg', query: 'validate' })
+ .execute(new AbortController().signal);
+
+ expect(result.llmContent).toContain('Regular search failed.');
+ expect(result.llmContent).toContain('ripgrep not found');
+ });
+
+ it('returns a grep_search-style display for no matches', async () => {
+ const root = createTempRoot();
+ queueSpawnResult({ stderr: 'diagnostic warning\n' });
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'missing',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('No matches found');
+ expect(String(result.llmContent)).not.toContain('diagnostic warning');
+ expect(result.returnDisplay).toBe('No matches found');
+ });
+
+ it('accepts grep_search-compatible pattern and path aliases', async () => {
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: 'src/index.ts:1\n 1 export const validate = true;\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'wrong-query',
+ pattern: 'validate',
+ glob: '**/*.ts',
+ path: 'src',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(String(result.llmContent)).toContain('src/index.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(1);
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ 'validate',
+ '--glob',
+ '**/*.ts',
+ '--',
+ 'src',
+ ]);
+ });
+
+ it('records exact search result files as partial reads', async () => {
+ const root = createTempRoot();
+ const fileReadCache = new FileReadCache();
+ const filePath = path.join(root, 'src', 'index.ts');
+ fs.mkdirSync(path.dirname(filePath));
+ fs.writeFileSync(filePath, 'export const validate = true;\n');
+ queueSpawnResult({
+ stdout: 'src/index.ts:1:export const validate = true;\n',
+ });
+
+ const invocation = createTool(root, true, fileReadCache).build({
+ operation: 'rg',
+ query: 'validate',
+ paths: ['src'],
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.resultFilePaths).toEqual([filePath]);
+ expect(result.returnDisplay).toBe('Found 1 match');
+ const readState = fileReadCache.check(fs.statSync(filePath));
+ expect(readState.state).toBe('fresh');
+ if (readState.state !== 'fresh') {
+ throw new Error('expected fresh read state');
+ }
+ expect(readState.entry.lastReadWasFull).toBe(false);
+ });
+
+ it('checks each duplicate result path only once', async () => {
+ const root = createTempRoot();
+ const filePath = path.join(root, 'src', 'index.ts');
+ fs.mkdirSync(path.dirname(filePath));
+ fs.writeFileSync(filePath, 'export const validate = true;\n');
+ queueSpawnResult({
+ stdout: [
+ 'src/index.ts:1:export const validate = true;',
+ 'src/index.ts:2:validate();',
+ '',
+ ].join('\n'),
+ });
+ const existsSync = vi.spyOn(fs, 'existsSync');
+
+ try {
+ const result = await createTool(root)
+ .build({ operation: 'rg', query: 'validate' })
+ .execute(new AbortController().signal);
+
+ expect(result.resultFilePaths).toEqual([filePath]);
+ expect(
+ existsSync.mock.calls.filter(([candidate]) => candidate === filePath),
+ ).toHaveLength(1);
+ } finally {
+ existsSync.mockRestore();
+ }
+ });
+
+ it('counts Windows absolute path result lines', async () => {
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: 'C:\\repo\\src\\index.ts:12:export const validate = true;\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'validate',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.returnDisplay).toBe('Found 1 match');
+ });
+
+ it('cleans background index metadata and log files when the job exits', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ removeWorkspaceJobFiles(root);
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({ stdout: 'indexing complete\n' });
+ queueSpawnResult({
+ stdout: 'src/index.ts:1\n 1 vector index metadata storage\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'vector index metadata storage',
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+ await invocation.execute(new AbortController().signal);
+
+ expect(listWorkspaceJobFiles(root)).toEqual([]);
+ });
+
+ it('cleans the background log when the index spawn throws', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ spawnMock.mockImplementationOnce((() => {
+ throw new Error('spawn failed');
+ }) as unknown as typeof spawn);
+ queueSpawnResult({
+ stdout: 'src/index.ts:1\n 1 vector index metadata storage\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'vector index metadata storage',
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+ await invocation.execute(new AbortController().signal);
+
+ expect(listWorkspaceJobFiles(root)).toEqual([]);
+ });
+
+ it('cleans the background log when the index child has no pid', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ const child = createMockChild();
+ child.pid = 0;
+ queueSpawnChild(child);
+ queueSpawnResult({
+ stdout: 'src/index.ts:1\n 1 vector index metadata storage\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'vector index metadata storage',
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+ await invocation.execute(new AbortController().signal);
+
+ expect(listWorkspaceJobFiles(root)).toEqual([]);
+ });
+
+ it('does not start a second background index job for the workspace', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ const isRunning = vi.spyOn(process, 'kill').mockReturnValue(true);
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ const indexChild = createMockChild();
+ queueSpawnChild(indexChild);
+ queueSpawnResult({
+ stdout: 'src/index.ts:1\n 1 vector index metadata storage\n',
+ });
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({
+ stdout: 'src/index.ts:1\n 1 vector index metadata storage\n',
+ });
+
+ try {
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'vector index metadata storage',
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+ await invocation.execute(new AbortController().signal);
+ await invocation.execute(new AbortController().signal);
+
+ expect(
+ spawnMock.mock.calls.filter((call) => call[1]?.[0] === 'index'),
+ ).toHaveLength(1);
+ } finally {
+ indexChild.emit('exit', 0);
+ isRunning.mockRestore();
+ }
+ });
+
+ it('returns an error without spawning when the abort signal is already aborted', async () => {
+ const root = createTempRoot();
+ const controller = new AbortController();
+ controller.abort();
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'validate',
+ });
+ const result = await invocation.execute(controller.signal);
+
+ expect(String(result.llmContent)).toContain('aborted');
+ expect(spawnMock).not.toHaveBeenCalled();
+ });
+
+ it('kills a running zvec-grep search when aborted', async () => {
+ vi.useFakeTimers();
+ const root = createTempRoot();
+ const child = createMockChild();
+ spawnMock.mockImplementationOnce((() => child) as unknown as typeof spawn);
+ const controller = new AbortController();
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'validate',
+ });
+ const promise = invocation.execute(controller.signal);
+ controller.abort();
+ const result = await promise;
+
+ expect(child.kill).toHaveBeenCalledWith('SIGTERM');
+ expect(String(result.llmContent)).toContain('aborted');
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL');
+ });
+
+ it('kills timed-out searches and reports the timeout', async () => {
+ vi.useFakeTimers();
+ const root = createTempRoot();
+ const child = createMockChild();
+ spawnMock.mockImplementationOnce((() => child) as unknown as typeof spawn);
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'validate',
+ });
+ const promise = invocation.execute(new AbortController().signal);
+
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(child.kill).toHaveBeenCalledWith('SIGTERM');
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL');
+ child.emit('close', null);
+ const result = await promise;
+
+ expect(String(result.llmContent)).toContain('timed out after 10000ms');
+ });
+
+ it('caps oversized zvec-grep output before returning it', async () => {
+ const root = createTempRoot();
+ queueSpawnResult({
+ stdout: `src/index.ts:1:export const validate = true;\n${'x'.repeat(
+ 20_000_050,
+ )}`,
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'validate',
+ });
+ const result = await invocation.execute(new AbortController().signal);
+
+ expect(result.returnDisplay).toBe('Found 1 match (truncated)');
+ expect(String(result.llmContent)).toContain('Output was truncated');
+ });
+
+ it('recursively expands brace globs before passing them to zg query --rg', async () => {
+ const root = createTempRoot();
+ queueSpawnResult({ stdout: 'src/a.cc:1\n 1 validate();\n' });
+
+ const invocation = createTool(root).build({
+ operation: 'rg',
+ query: 'validate',
+ glob: '*.{h,cc}.test.{ts,tsx}',
+ paths: ['src'],
+ });
+
+ await invocation.execute(new AbortController().signal);
+
+ expect(spawnMock).toHaveBeenCalledTimes(1);
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ 'validate',
+ '--glob',
+ '*.h.test.ts',
+ '--glob',
+ '*.h.test.tsx',
+ '--glob',
+ '*.cc.test.ts',
+ '--glob',
+ '*.cc.test.tsx',
+ '--',
+ 'src',
+ ]);
+ });
+
+ it('installs zvec-grep only after setup approval', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ const error = Object.assign(new Error('spawn zg ENOENT'), {
+ code: 'ENOENT',
+ }) as NodeJS.ErrnoException;
+ queueSpawnResult({ error });
+ queueSpawnResult({ error });
+ queueSpawnResult({ stdout: 'added 1 package\n' });
+ queueSpawnResult({ stdout: UNINDEXED_STATUS });
+ queueSpawnResult({});
+ queueSpawnResult({
+ stdout: 'src/auth.ts:1\n 1 authentication flow\n',
+ });
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ const details = await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+ expect(details.questions[0]?.question).toContain(
+ 'install @zvec/zvec-grep@0.1.5 globally with npm',
+ );
+ const result = await invocation.execute(new AbortController().signal);
+ const content = String(result.llmContent);
+
+ expect(content).toContain('src/auth.ts:1');
+ expect(spawnMock).toHaveBeenCalledTimes(6);
+ expect(spawnMock.mock.calls[0]?.[0]).toBe('zg');
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[1]?.[0]).toBe('zg');
+ expect(spawnMock.mock.calls[1]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[2]?.[0]).toBe('npm');
+ expect(spawnMock.mock.calls[2]?.[1]).toEqual([
+ 'install',
+ '-g',
+ '@zvec/zvec-grep@0.1.5',
+ ]);
+ expect(spawnMock.mock.calls[2]?.[2]?.env).not.toHaveProperty(
+ 'DASHSCOPE_API_KEY',
+ );
+ expect(spawnMock.mock.calls[2]?.[2]?.env).toHaveProperty('PATH');
+ expect(spawnMock.mock.calls[3]?.[0]).toBe('zg');
+ expect(spawnMock.mock.calls[3]?.[1]).toEqual(['status']);
+ expect(spawnMock.mock.calls[4]?.[0]).toBe('zg');
+ expect(spawnMock.mock.calls[4]?.[1]).toEqual([
+ 'index',
+ '--embedding',
+ 'qwen/text-embedding-v4',
+ ]);
+ expect(spawnMock.mock.calls[5]?.[0]).toBe('zg');
+ expect(spawnMock.mock.calls[5]?.[1]).toEqual([
+ 'query',
+ '--rg',
+ '-e',
+ '(?i)(authentication|flow)',
+ '--limit',
+ '20',
+ ]);
+ });
+
+ it('terminates an approved install when the invocation is aborted', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ const unavailable = Object.assign(new Error('spawn zg ENOENT'), {
+ code: 'ENOENT',
+ }) as NodeJS.ErrnoException;
+ queueSpawnResult({ error: unavailable });
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+
+ queueSpawnResult({ error: unavailable });
+ const installChild = createMockChild();
+ queueSpawnChild(installChild);
+ queueRipgrepResult(
+ `${path.join(root, 'src/auth.ts')}:1:authentication flow\n`,
+ );
+ const controller = new AbortController();
+ const promise = invocation.execute(controller.signal);
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(3));
+ vi.useFakeTimers();
+
+ controller.abort();
+ const result = await promise;
+
+ expect(installChild.kill).toHaveBeenCalledWith('SIGTERM');
+ expect(String(result.llmContent)).toContain('src/auth.ts:1');
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(installChild.kill).toHaveBeenCalledWith('SIGKILL');
+ });
+
+ it('force-kills an approved install after its timeout', async () => {
+ vi.useFakeTimers();
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ const unavailable = Object.assign(new Error('spawn zg ENOENT'), {
+ code: 'ENOENT',
+ }) as NodeJS.ErrnoException;
+ queueSpawnResult({ error: unavailable });
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+ const permission = invocation.getDefaultPermission();
+ await vi.runAllTicks();
+ await expect(permission).resolves.toBe('ask');
+ await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+
+ queueSpawnResult({ error: unavailable });
+ const installChild = createMockChild();
+ queueSpawnChild(installChild);
+ queueRipgrepResult(
+ `${path.join(root, 'src/auth.ts')}:1:authentication flow\n`,
+ );
+ const promise = invocation.execute(new AbortController().signal);
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(3));
+ await vi.advanceTimersByTimeAsync(120_000);
+ expect(installChild.kill).toHaveBeenCalledWith('SIGTERM');
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(installChild.kill).toHaveBeenCalledWith('SIGKILL');
+ installChild.emit('close', null);
+ const result = await promise;
+
+ expect(String(result.llmContent)).toContain('src/auth.ts:1');
+ });
+
+ it('offers the pinned install when zg status is incompatible', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ queueSpawnResult({
+ code: 1,
+ stderr: 'Unknown command: status\n',
+ });
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ const details = await invocation.getConfirmationDetails(
+ new AbortController().signal,
+ );
+
+ expect(details.type).toBe('ask_user_question');
+ if (details.type === 'ask_user_question') {
+ expect(details.questions[0]?.question).toContain(
+ 'install @zvec/zvec-grep@0.1.5 globally with npm',
+ );
+ }
+ expect(spawnMock.mock.calls[0]?.[1]).toEqual(['status']);
+ });
+
+ it('uses native rg and reports setup failure when approved install fails', async () => {
+ setFakeRemoteEmbeddingKey();
+ const root = createTempRoot();
+ const error = Object.assign(new Error('spawn zg ENOENT'), {
+ code: 'ENOENT',
+ }) as NodeJS.ErrnoException;
+ queueSpawnResult({ error });
+ queueSpawnResult({ error });
+ queueSpawnResult({
+ code: 1,
+ stderr: 'npm registry unavailable\n',
+ });
+ queueRipgrepResult(
+ `${path.join(root, 'src/auth.ts')}:1:authentication flow\n`,
+ );
+
+ const invocation = createTool(root).build({
+ operation: 'semantic',
+ query: 'authentication flow',
+ });
+ await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
+ await chooseSetup(invocation, ENABLE_WORKSPACE_CHOICE);
+ const result = await invocation.execute(new AbortController().signal);
+ const content = String(result.llmContent);
+
+ expect(content).toContain('src/auth.ts:1');
+ expect(content).not.toContain('npm registry unavailable');
+ expect(String(result.returnDisplay)).toContain(
+ 'Enhanced search setup failed; regular search was used.',
+ );
+ expect(spawnMock).toHaveBeenCalledTimes(3);
+ expect(runRipgrepMock).toHaveBeenCalledTimes(1);
+
+ queueSpawnResult({ error });
+ queueSpawnResult({ code: 1, stderr: 'still unavailable\n' });
+ queueRipgrepResult(
+ `${path.join(root, 'src/auth.ts')}:1:authentication flow\n`,
+ );
+ await invocation.execute(new AbortController().signal);
+
+ expect(spawnMock).toHaveBeenCalledTimes(5);
+ expect(spawnMock.mock.calls[4]?.[0]).toBe('npm');
+ expect(runRipgrepMock).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/core/src/tools/zvec-grep.ts b/packages/core/src/tools/zvec-grep.ts
new file mode 100644
index 00000000000..cb7ec52ef7c
--- /dev/null
+++ b/packages/core/src/tools/zvec-grep.ts
@@ -0,0 +1,1642 @@
+/**
+ * @license
+ * Copyright 2025 Qwen
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import crypto from 'node:crypto';
+import { spawn, type ChildProcess } from 'node:child_process';
+import picomatch from 'picomatch';
+import type { Config } from '../config/config.js';
+import type { PermissionDecision } from '../permissions/types.js';
+import type {
+ ToolAskUserQuestionConfirmationDetails,
+ ToolCallConfirmationDetails,
+ ToolConfirmationPayload,
+ ToolInvocation,
+ ToolResult,
+ ToolResultDisplay,
+} from './tools.js';
+import {
+ BaseDeclarativeTool,
+ BaseToolInvocation,
+ Kind,
+ ToolConfirmationOutcome,
+} from './tools.js';
+import { ToolDisplayNames, ToolNames } from './tool-names.js';
+import { getMemoryBaseDir } from '../memory/paths.js';
+import { isSubpath, resolvePath } from '../utils/paths.js';
+import { createDebugLogger } from '../utils/debugLogger.js';
+import { runRipgrep, type RipgrepRunResult } from '../utils/ripgrepUtils.js';
+import { getErrorMessage } from '../utils/errors.js';
+import { recordGrepResultFileReads } from './grepReadTracking.js';
+
+const DEFAULT_EMBEDDING_MODEL = 'qwen/text-embedding-v4';
+const ZVEC_GREP_NPM_PACKAGE = '@zvec/zvec-grep@0.1.5';
+const REMOTE_EMBEDDING_API_KEY_ENV_NAMES = [
+ 'ZVEC_GREP_API_KEY',
+ 'DASHSCOPE_API_KEY',
+ 'QWEN_API_KEY',
+] as const;
+const BACKGROUND_JOB_DIR = path.join(os.tmpdir(), 'qwen-zvec-grep-index');
+const ZG_OUTPUT_LIMIT = 20_000_000;
+const ZG_RUN_TIMEOUT_MS = 10_000;
+const ZG_WSL_TIMEOUT_MS = 60_000;
+const ZG_INSTALL_OUTPUT_LIMIT = 200_000;
+const ZG_INSTALL_TIMEOUT_MS = 120_000;
+const ZG_KILL_GRACE_MS = 5_000;
+const DEFAULT_SEMANTIC_LIMIT = 20;
+const SEMANTIC_FALLBACK_TOKEN_LIMIT = 12;
+const ZG_RESULT_LINE_RE = /^((?:[A-Za-z]:)?[^:\s][^:]*):\d+(?:-\d+)?(?::|\s|$)/;
+const ENABLE_WORKSPACE_CHOICE = 'Enable for this workspace';
+const NOT_THIS_SESSION_CHOICE = 'Not this session';
+const DISABLE_WORKSPACE_CHOICE = 'Disable for this workspace';
+const debugLogger = createDebugLogger('ZVEC_GREP');
+
+const SEMANTIC_FALLBACK_STOP_WORDS = new Set([
+ 'able',
+ 'about',
+ 'after',
+ 'all',
+ 'also',
+ 'and',
+ 'any',
+ 'are',
+ 'around',
+ 'as',
+ 'be',
+ 'been',
+ 'being',
+ 'between',
+ 'by',
+ 'called',
+ 'can',
+ 'check',
+ 'checking',
+ 'class',
+ 'classes',
+ 'code',
+ 'component',
+ 'components',
+ 'current',
+ 'describe',
+ 'did',
+ 'do',
+ 'does',
+ 'during',
+ 'explain',
+ 'file',
+ 'files',
+ 'find',
+ 'full',
+ 'function',
+ 'functions',
+ 'get',
+ 'give',
+ 'for',
+ 'from',
+ 'handle',
+ 'handled',
+ 'handles',
+ 'handling',
+ 'has',
+ 'have',
+ 'how',
+ 'in',
+ 'implementation',
+ 'implementations',
+ 'implemented',
+ 'implements',
+ 'inside',
+ 'into',
+ 'is',
+ 'it',
+ 'its',
+ 'list',
+ 'look',
+ 'looking',
+ 'method',
+ 'methods',
+ 'module',
+ 'modules',
+ 'need',
+ 'needs',
+ 'now',
+ 'of',
+ 'on',
+ 'or',
+ 'part',
+ 'parts',
+ 'please',
+ 'project',
+ 'related',
+ 'relevant',
+ 'repo',
+ 'repository',
+ 'search',
+ 'show',
+ 'should',
+ 'support',
+ 'supported',
+ 'supports',
+ 'that',
+ 'their',
+ 'there',
+ 'these',
+ 'they',
+ 'text',
+ 'the',
+ 'this',
+ 'to',
+ 'use',
+ 'used',
+ 'uses',
+ 'using',
+ 'was',
+ 'way',
+ 'we',
+ 'what',
+ 'when',
+ 'where',
+ 'with',
+ 'why',
+ 'work',
+ 'working',
+ 'works',
+ 'workspace',
+ 'would',
+]);
+
+type ZvecGrepOperation = 'semantic' | 'rg';
+
+interface ZvecGrepParams {
+ operation: ZvecGrepOperation;
+ query?: string;
+ pattern?: string;
+ path?: string;
+ paths?: string[];
+ glob?: string;
+ exclude?: string[];
+ limit?: number;
+}
+
+interface ZgCommandResult {
+ ok: boolean;
+ code: number | null;
+ stdout: string;
+ stderr: string;
+ truncated?: boolean;
+ unavailable?: boolean;
+ error?: string;
+}
+
+interface ParsedStatus {
+ ready: boolean;
+ disabled: boolean;
+ indexing: boolean;
+ unindexed: boolean;
+ raw: string;
+}
+
+interface BackgroundIndexJob {
+ pid: number;
+ cwd: string;
+ args: string[];
+ logPath: string;
+ startedAt: string;
+}
+
+interface ZvecGrepSessionState {
+ useNativeGrep: boolean;
+}
+
+interface SetupPromptState {
+ required: boolean;
+ needsInstall: boolean;
+ parsedStatus?: ParsedStatus;
+}
+
+let zvecGrepInstallPromise: Promise | undefined;
+
+export function _resetZvecGrepInstallForTest(): void {
+ zvecGrepInstallPromise = undefined;
+}
+
+function shellQuoteForDisplay(arg: string): string {
+ if (/^[A-Za-z0-9_./:=,@%+-]+$/.test(arg)) {
+ return arg;
+ }
+ return `'${arg.replaceAll("'", "'\\''")}'`;
+}
+
+function formatZgCommand(args: readonly string[]): string {
+ return ['zg', ...args].map(shellQuoteForDisplay).join(' ');
+}
+
+function normalizeOptionalString(
+ value: string | undefined,
+): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed || undefined;
+}
+
+function normalizeStringArray(value: string[] | undefined): string[] {
+ return (value ?? []).map((item) => item.trim()).filter(Boolean);
+}
+
+function getSearchQuery(params: ZvecGrepParams): string | undefined {
+ const query =
+ params.operation === 'rg'
+ ? params.pattern?.trim() || params.query?.trim()
+ : params.query?.trim() || params.pattern?.trim();
+ return query || undefined;
+}
+
+function normalizeSearchPaths(params: ZvecGrepParams): string[] {
+ const paths = normalizeStringArray(params.paths);
+ const singlePath = normalizeOptionalString(params.path);
+ return singlePath ? [singlePath, ...paths] : paths;
+}
+
+function pathLooksLikeGlob(value: string): boolean {
+ return /[*?[\]{}]/.test(value);
+}
+
+function normalizeScopePath(value: string): string {
+ let normalized = value.trim().replaceAll('\\', '/');
+ while (normalized.endsWith('/')) {
+ normalized = normalized.slice(0, -1);
+ }
+ return normalized;
+}
+
+function pathToScopeGlobs(value: string): string[] {
+ const trimmed = normalizeScopePath(value);
+ if (!trimmed || pathLooksLikeGlob(trimmed)) return [trimmed];
+ return [trimmed, `${trimmed}/**`];
+}
+
+function expandBraceAlternates(value: string): string[] {
+ const match = value.match(/^(.*?)\{([^{}]+)\}(.*)$/);
+ if (!match) return [value];
+
+ const [, before, inner, after] = match;
+ const parts = inner
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+ if (parts.length === 0) return [value];
+ return parts.flatMap((item) =>
+ expandBraceAlternates(`${before}${item}${after}`),
+ );
+}
+
+function expandGlobs(values: string[]): string[] {
+ return values.flatMap(expandBraceAlternates);
+}
+
+function intersectScopeAndGlob(scope: string, glob: string): string[] {
+ const normalizedScope = normalizeScopePath(scope);
+ let normalizedGlob = glob.trim().replaceAll('\\', '/');
+ while (normalizedGlob.startsWith('./')) {
+ normalizedGlob = normalizedGlob.slice(2);
+ }
+ while (normalizedGlob.startsWith('/')) {
+ normalizedGlob = normalizedGlob.slice(1);
+ }
+ if (!normalizedScope) return [normalizedGlob];
+
+ const descendantGlob = normalizedGlob.includes('/')
+ ? normalizedGlob
+ : `**/${normalizedGlob}`;
+ const patterns = [`${normalizedScope}/${descendantGlob}`];
+ if (
+ !pathLooksLikeGlob(normalizedScope) &&
+ picomatch.isMatch(normalizedScope, normalizedGlob, {
+ basename: !normalizedGlob.includes('/'),
+ })
+ ) {
+ patterns.unshift(normalizedScope);
+ }
+ return patterns;
+}
+
+function addScopeArgs(args: string[], params: ZvecGrepParams): void {
+ const paths = normalizeSearchPaths(params);
+ const glob = normalizeOptionalString(params.glob);
+ const expandedGlobs = expandGlobs(glob ? [glob] : []);
+ const include =
+ paths.length === 0
+ ? expandedGlobs
+ : expandedGlobs.length === 0
+ ? expandGlobs(paths.flatMap(pathToScopeGlobs))
+ : expandGlobs(
+ paths.flatMap((scope) =>
+ expandedGlobs.flatMap((pattern) =>
+ intersectScopeAndGlob(scope, pattern),
+ ),
+ ),
+ );
+ const exclude = expandGlobs(normalizeStringArray(params.exclude));
+
+ for (const pattern of include) {
+ args.push('--glob', pattern);
+ }
+ for (const pattern of exclude) {
+ args.push('--glob', pattern.startsWith('!') ? pattern : `!${pattern}`);
+ }
+}
+
+function buildSearchArgs(params: ZvecGrepParams): string[] {
+ const searchQuery = getSearchQuery(params);
+ const args = [
+ 'query',
+ '--limit',
+ String(params.limit ?? DEFAULT_SEMANTIC_LIMIT),
+ ];
+ addScopeArgs(args, params);
+ if (searchQuery) args.push('--', searchQuery);
+ return args;
+}
+
+function buildGrepArgs(params: ZvecGrepParams): string[] {
+ const searchQuery = getSearchQuery(params);
+ const args = ['query', '--rg'];
+ if (searchQuery) {
+ args.push('-e', searchQuery);
+ }
+ if (params.limit !== undefined) {
+ args.push('--limit', String(params.limit));
+ }
+
+ const glob = normalizeOptionalString(params.glob);
+ if (glob) {
+ for (const expandedGlob of expandBraceAlternates(glob)) {
+ args.push('--glob', expandedGlob);
+ }
+ }
+ for (const item of expandGlobs(normalizeStringArray(params.exclude))) {
+ args.push('--glob', item.startsWith('!') ? item : `!${item}`);
+ }
+ const searchPaths = normalizeSearchPaths(params);
+ if (searchPaths.length > 0) args.push('--', ...searchPaths);
+ return args;
+}
+
+function escapeRgRegex(value: string): string {
+ return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
+}
+
+function looksCodeLike(token: string): boolean {
+ return (
+ token.includes('_') ||
+ /[a-z][A-Z]/.test(token) ||
+ /[A-Z]{2,}/.test(token) ||
+ /\d/.test(token)
+ );
+}
+
+function buildSemanticFallbackQuery(query: string): string {
+ const allTokens: string[] = [];
+ const codeLikeTokens: string[] = [];
+ for (const match of query.matchAll(/[\p{L}\p{N}_]+/gu)) {
+ const token = match[0];
+ const normalized = token.toLowerCase();
+ if (normalized.length < 3) continue;
+ if (SEMANTIC_FALLBACK_STOP_WORDS.has(normalized)) continue;
+ allTokens.push(token);
+ if (looksCodeLike(token)) {
+ codeLikeTokens.push(token);
+ }
+ if (allTokens.length >= SEMANTIC_FALLBACK_TOKEN_LIMIT) break;
+ }
+
+ const selected = codeLikeTokens.length > 0 ? codeLikeTokens : allTokens;
+ const tokens = [...new Set(selected)].map(escapeRgRegex);
+ if (tokens.length === 0) return `(?i)${escapeRgRegex(query)}`;
+ if (tokens.length === 1) return `(?i)${tokens[0]}`;
+ return `(?i)(${tokens.join('|')})`;
+}
+
+function buildSemanticFallbackGrepArgs(params: ZvecGrepParams): string[] {
+ const query = getSearchQuery(params);
+ const fallbackParams = {
+ ...params,
+ limit: params.limit ?? DEFAULT_SEMANTIC_LIMIT,
+ };
+ if (!query) return buildGrepArgs(fallbackParams);
+ return buildGrepArgs({
+ ...fallbackParams,
+ query: buildSemanticFallbackQuery(query),
+ });
+}
+
+function zvecRunTimeoutMs(): number {
+ return process.platform === 'linux' && process.env['WSL_INTEROP']
+ ? ZG_WSL_TIMEOUT_MS
+ : ZG_RUN_TIMEOUT_MS;
+}
+
+function getConfiguredEmbeddingModel(): string | undefined {
+ return normalizeOptionalString(process.env['ZVEC_GREP_EMBEDDING']);
+}
+
+function isLocalEmbeddingModel(embedding: string | undefined): boolean {
+ return embedding?.startsWith('local/') === true;
+}
+
+function hasRemoteEmbeddingApiKey(): boolean {
+ return REMOTE_EMBEDDING_API_KEY_ENV_NAMES.some((name) =>
+ Boolean(normalizeOptionalString(process.env[name])),
+ );
+}
+
+function statusLooksLocalEmbedding(output: string): boolean {
+ return /\bembedding\s+local\//i.test(output);
+}
+
+function canUseSemanticEmbedding(parsed?: ParsedStatus): boolean {
+ const configuredEmbedding = getConfiguredEmbeddingModel();
+ return (
+ isLocalEmbeddingModel(configuredEmbedding) ||
+ hasRemoteEmbeddingApiKey() ||
+ (parsed ? statusLooksLocalEmbedding(parsed.raw) : false)
+ );
+}
+
+function getIndexEmbeddingModel(): string {
+ return getConfiguredEmbeddingModel() ?? DEFAULT_EMBEDDING_MODEL;
+}
+
+function buildIndexArgs(): string[] {
+ return ['index', '--embedding', getIndexEmbeddingModel()];
+}
+
+function parseStatus(output: string): ParsedStatus {
+ const text = output.trim();
+ const lowered = text.toLowerCase();
+ return {
+ ready:
+ /\bindexed\s+yes\b/.test(lowered) ||
+ /\bstate\s+ready\b/.test(lowered) ||
+ /\bstate:\s*ready\b/.test(lowered) ||
+ /\bstatus\s+ready\b/.test(lowered) ||
+ /\bstatus:\s*ready\b/.test(lowered),
+ disabled:
+ /\bpolicy\s+disabled\b/.test(lowered) ||
+ /\bpolicy:\s*disabled\b/.test(lowered),
+ indexing:
+ /\b(state|status)\s+(indexing|building|running|in[_ -]?progress)\b/.test(
+ lowered,
+ ) ||
+ /\b(state|status):\s*(indexing|building|running|in[_ -]?progress)\b/.test(
+ lowered,
+ ) ||
+ /\bindexing\s+yes\b/.test(lowered) ||
+ /\bindexing:\s*yes\b/.test(lowered),
+ unindexed:
+ /\bindexed\s+no\b/.test(lowered) ||
+ /\bsource\s+unindexed\b/.test(lowered) ||
+ /\bpolicy\s+undecided\b/.test(lowered) ||
+ /\bpolicy:\s*undecided\b/.test(lowered),
+ raw: text,
+ };
+}
+
+function validateRawStringArrayField(
+ params: ZvecGrepParams,
+ field: 'paths' | 'exclude',
+): string | null {
+ const value = (params as unknown as Record)[field];
+ if (value === undefined || !Array.isArray(value)) return null;
+ if (value.some((item) => typeof item !== 'string' || !item.trim())) {
+ return `${field} must contain only non-empty strings`;
+ }
+ return null;
+}
+
+function getWorkspaceJobKey(cwd: string): string {
+ return crypto.createHash('sha1').update(cwd).digest('hex').slice(0, 16);
+}
+
+function getBackgroundJobPath(cwd: string): string {
+ return path.join(BACKGROUND_JOB_DIR, `${getWorkspaceJobKey(cwd)}.index.json`);
+}
+
+function removeBackgroundJobFiles(jobPath: string, logPath?: string): void {
+ try {
+ fs.rmSync(jobPath, { force: true });
+ } catch {
+ // Best effort cleanup only.
+ }
+ removeBackgroundLogFile(logPath);
+}
+
+function removeBackgroundLogFile(logPath?: string): void {
+ if (
+ logPath &&
+ path.dirname(path.resolve(logPath)) === path.resolve(BACKGROUND_JOB_DIR)
+ ) {
+ try {
+ fs.rmSync(logPath, { force: true });
+ } catch {
+ // Best effort cleanup only.
+ }
+ }
+}
+
+function isProcessRunning(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function readBackgroundIndexJob(cwd: string): BackgroundIndexJob | undefined {
+ const jobPath = getBackgroundJobPath(cwd);
+ if (!fs.existsSync(jobPath)) return undefined;
+
+ try {
+ const parsed = JSON.parse(
+ fs.readFileSync(jobPath, 'utf8'),
+ ) as Partial;
+ if (
+ parsed.cwd !== cwd ||
+ typeof parsed.pid !== 'number' ||
+ !Array.isArray(parsed.args) ||
+ typeof parsed.logPath !== 'string' ||
+ typeof parsed.startedAt !== 'string'
+ ) {
+ removeBackgroundJobFiles(
+ jobPath,
+ typeof parsed.logPath === 'string' ? parsed.logPath : undefined,
+ );
+ return undefined;
+ }
+ if (!isProcessRunning(parsed.pid)) {
+ removeBackgroundJobFiles(jobPath, parsed.logPath);
+ return undefined;
+ }
+ return {
+ pid: parsed.pid,
+ cwd: parsed.cwd,
+ args: parsed.args.filter((arg): arg is string => typeof arg === 'string'),
+ logPath: parsed.logPath,
+ startedAt: parsed.startedAt,
+ };
+ } catch {
+ removeBackgroundJobFiles(jobPath);
+ return undefined;
+ }
+}
+
+function startBackgroundIndexJob(
+ cwd: string,
+ args: readonly string[],
+): BackgroundIndexJob {
+ fs.mkdirSync(BACKGROUND_JOB_DIR, { recursive: true });
+
+ const jobKey = getWorkspaceJobKey(cwd);
+ const logPath = path.join(BACKGROUND_JOB_DIR, `${jobKey}-${Date.now()}.log`);
+ const logFd = fs.openSync(logPath, 'a');
+ let child;
+ try {
+ child = spawn('zg', args, {
+ cwd,
+ detached: true,
+ env: zvecGrepChildEnv(),
+ windowsHide: true,
+ stdio: ['ignore', logFd, logFd],
+ });
+ } catch (error) {
+ removeBackgroundLogFile(logPath);
+ throw error;
+ } finally {
+ fs.closeSync(logFd);
+ }
+
+ if (!child.pid) {
+ removeBackgroundLogFile(logPath);
+ throw new Error('failed to start zvec-grep index process');
+ }
+
+ const job: BackgroundIndexJob = {
+ pid: child.pid,
+ cwd,
+ args: [...args],
+ logPath,
+ startedAt: new Date().toISOString(),
+ };
+ const jobPath = getBackgroundJobPath(cwd);
+ const cleanupJob = () => {
+ removeBackgroundJobFiles(jobPath, job.logPath);
+ };
+ child.once('error', cleanupJob);
+ child.once('exit', cleanupJob);
+ fs.writeFileSync(jobPath, JSON.stringify(job, null, 2));
+ child.unref();
+ return job;
+}
+
+function startApprovedBackgroundIndex(
+ cwd: string,
+ parsed: ParsedStatus,
+): boolean {
+ if (parsed.ready || parsed.disabled) return false;
+ if (parsed.indexing) return true;
+ if (!parsed.unindexed) return false;
+ if (readBackgroundIndexJob(cwd)) return true;
+ if (!canUseSemanticEmbedding()) return false;
+ try {
+ startBackgroundIndexJob(cwd, buildIndexArgs());
+ return true;
+ } catch (error) {
+ debugLogger.debug('Failed to start zvec-grep background index', error);
+ return false;
+ }
+}
+
+function pathListSeparator(): string {
+ return process.platform === 'win32' ? ';' : ':';
+}
+
+function npmGlobalBinDir(prefix: string): string {
+ return process.platform === 'win32' ? prefix : path.join(prefix, 'bin');
+}
+
+function zvecGrepPathDirs(): string[] {
+ const dirs = [
+ process.env['npm_config_prefix']
+ ? npmGlobalBinDir(process.env['npm_config_prefix'])
+ : undefined,
+ path.dirname(process.execPath),
+ npmGlobalBinDir(path.join(os.homedir(), '.npm-global')),
+ ];
+ return [...new Set(dirs.filter((dir): dir is string => Boolean(dir)))];
+}
+
+function zvecGrepChildEnv(): NodeJS.ProcessEnv {
+ const separator = pathListSeparator();
+ return {
+ ...process.env,
+ PATH: [
+ ...zvecGrepPathDirs(),
+ ...(normalizeOptionalString(process.env['PATH'])?.split(separator) ?? []),
+ ]
+ .filter(Boolean)
+ .filter((value, index, values) => values.indexOf(value) === index)
+ .join(separator),
+ };
+}
+
+function zvecGrepInstallEnv(): NodeJS.ProcessEnv {
+ const env: NodeJS.ProcessEnv = {
+ PATH: zvecGrepChildEnv()['PATH'],
+ };
+ for (const name of [
+ 'HOME',
+ 'USERPROFILE',
+ 'APPDATA',
+ 'LOCALAPPDATA',
+ 'SystemRoot',
+ 'ComSpec',
+ 'PATHEXT',
+ 'TMPDIR',
+ 'TMP',
+ 'TEMP',
+ 'LANG',
+ 'LC_ALL',
+ 'HTTP_PROXY',
+ 'HTTPS_PROXY',
+ 'NO_PROXY',
+ 'http_proxy',
+ 'https_proxy',
+ 'no_proxy',
+ 'NODE_EXTRA_CA_CERTS',
+ 'SSL_CERT_FILE',
+ 'SSL_CERT_DIR',
+ 'npm_config_prefix',
+ 'npm_config_registry',
+ 'npm_config_cache',
+ 'npm_config_userconfig',
+ 'NPM_CONFIG_PREFIX',
+ 'NPM_CONFIG_REGISTRY',
+ 'NPM_CONFIG_CACHE',
+ 'NPM_CONFIG_USERCONFIG',
+ ]) {
+ if (process.env[name] !== undefined) env[name] = process.env[name];
+ }
+ return env;
+}
+
+function createChildTerminator(child: Pick): {
+ terminate: () => void;
+ clear: () => void;
+} {
+ let forceKillTimer: NodeJS.Timeout | undefined;
+ return {
+ terminate: () => {
+ if (forceKillTimer) return;
+ child.kill('SIGTERM');
+ forceKillTimer = setTimeout(() => {
+ child.kill('SIGKILL');
+ }, ZG_KILL_GRACE_MS);
+ forceKillTimer.unref();
+ },
+ clear: () => {
+ if (forceKillTimer) clearTimeout(forceKillTimer);
+ forceKillTimer = undefined;
+ },
+ };
+}
+
+function runInstallZvecGrep(signal: AbortSignal): Promise {
+ if (signal.aborted) {
+ return Promise.resolve({
+ ok: false,
+ code: null,
+ stdout: '',
+ stderr: '',
+ error: 'aborted',
+ });
+ }
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let stdout = '';
+ let stderr = '';
+ let truncated = false;
+ let timedOut = false;
+
+ const child = spawn('npm', ['install', '-g', ZVEC_GREP_NPM_PACKAGE], {
+ env: zvecGrepInstallEnv(),
+ windowsHide: true,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ const terminator = createChildTerminator(child);
+
+ const finish = (result: ZgCommandResult) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ signal.removeEventListener('abort', onAbort);
+ resolve(result);
+ };
+
+ const killForLimit = () => {
+ if (truncated) return;
+ truncated = true;
+ terminator.terminate();
+ };
+
+ const timer = setTimeout(() => {
+ timedOut = true;
+ killForLimit();
+ }, ZG_INSTALL_TIMEOUT_MS);
+
+ const onAbort = () => {
+ terminator.terminate();
+ finish({
+ ok: false,
+ code: null,
+ stdout,
+ stderr,
+ truncated,
+ error: 'aborted',
+ });
+ };
+
+ signal.addEventListener('abort', onAbort, { once: true });
+
+ const appendChunk = (target: 'stdout' | 'stderr', chunk: Buffer) => {
+ const text = chunk.toString('utf8');
+ const currentSize = stdout.length + stderr.length;
+ const remaining = ZG_INSTALL_OUTPUT_LIMIT - currentSize;
+ if (remaining <= 0) {
+ killForLimit();
+ return;
+ }
+
+ const nextText = text.slice(0, remaining);
+ if (target === 'stdout') {
+ stdout += nextText;
+ } else {
+ stderr += nextText;
+ }
+ if (nextText.length < text.length) {
+ killForLimit();
+ }
+ };
+
+ child.stdout?.on('data', (chunk: Buffer) => {
+ appendChunk('stdout', chunk);
+ });
+ child.stderr?.on('data', (chunk: Buffer) => {
+ appendChunk('stderr', chunk);
+ });
+
+ child.on('error', (error: NodeJS.ErrnoException) => {
+ terminator.clear();
+ finish({
+ ok: false,
+ code: null,
+ stdout,
+ stderr,
+ truncated,
+ unavailable: error.code === 'ENOENT',
+ error: error.message,
+ });
+ });
+
+ child.on('close', (code) => {
+ terminator.clear();
+ finish({
+ ok: code === 0,
+ code,
+ stdout,
+ stderr,
+ truncated,
+ error: timedOut
+ ? `timed out after ${ZG_INSTALL_TIMEOUT_MS}ms`
+ : truncated
+ ? `output exceeded ${ZG_INSTALL_OUTPUT_LIMIT} bytes`
+ : undefined,
+ });
+ });
+ });
+}
+
+function installZvecGrep(signal: AbortSignal): Promise {
+ if (!zvecGrepInstallPromise) {
+ zvecGrepInstallPromise = runInstallZvecGrep(signal).then((result) => {
+ if (!result.ok) {
+ zvecGrepInstallPromise = undefined;
+ }
+ return result;
+ });
+ }
+ return zvecGrepInstallPromise;
+}
+
+async function runZg(
+ args: readonly string[],
+ cwd: string,
+ signal: AbortSignal,
+ options: { allowPartialOutput?: boolean } = {},
+): Promise {
+ return runZgOnce(args, cwd, signal, options);
+}
+
+function runZgOnce(
+ args: readonly string[],
+ cwd: string,
+ signal: AbortSignal,
+ options: { allowPartialOutput?: boolean } = {},
+): Promise {
+ if (signal.aborted) {
+ return Promise.resolve({
+ ok: false,
+ code: null,
+ stdout: '',
+ stderr: '',
+ error: 'aborted',
+ });
+ }
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let stdout = '';
+ let stderr = '';
+ let truncated = false;
+ let timedOut = false;
+
+ const finish = (result: ZgCommandResult) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ signal.removeEventListener('abort', onAbort);
+ resolve(result);
+ };
+
+ const child = spawn('zg', args, {
+ cwd,
+ env: zvecGrepChildEnv(),
+ windowsHide: true,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ const terminator = createChildTerminator(child);
+
+ const killForLimit = () => {
+ if (truncated) return;
+ truncated = true;
+ terminator.terminate();
+ };
+
+ const timer = setTimeout(() => {
+ timedOut = true;
+ killForLimit();
+ }, zvecRunTimeoutMs());
+
+ const onAbort = () => {
+ terminator.terminate();
+ finish({
+ ok: false,
+ code: null,
+ stdout,
+ stderr,
+ truncated,
+ error: 'aborted',
+ });
+ };
+
+ signal.addEventListener('abort', onAbort, { once: true });
+
+ const appendChunk = (target: 'stdout' | 'stderr', chunk: Buffer) => {
+ const text = chunk.toString('utf8');
+ const currentSize = stdout.length + stderr.length;
+ const remaining = ZG_OUTPUT_LIMIT - currentSize;
+ if (remaining <= 0) {
+ killForLimit();
+ return;
+ }
+
+ const nextText = text.slice(0, remaining);
+ if (target === 'stdout') {
+ stdout += nextText;
+ } else {
+ stderr += nextText;
+ }
+ if (nextText.length < text.length) {
+ killForLimit();
+ }
+ };
+
+ child.stdout?.on('data', (chunk: Buffer) => {
+ appendChunk('stdout', chunk);
+ });
+ child.stderr?.on('data', (chunk: Buffer) => {
+ appendChunk('stderr', chunk);
+ });
+
+ child.on('error', (error: NodeJS.ErrnoException) => {
+ terminator.clear();
+ finish({
+ ok: false,
+ code: null,
+ stdout,
+ stderr,
+ truncated,
+ unavailable: error.code === 'ENOENT',
+ error: error.message,
+ });
+ });
+
+ child.on('close', (code) => {
+ terminator.clear();
+ const partialOutputOk =
+ options.allowPartialOutput === true &&
+ truncated &&
+ stdout.trim().length > 0;
+ finish({
+ ok: code === 0 || partialOutputOk,
+ code,
+ stdout,
+ stderr,
+ truncated,
+ error: timedOut
+ ? `timed out after ${zvecRunTimeoutMs()}ms`
+ : truncated
+ ? `output exceeded ${ZG_OUTPUT_LIMIT} bytes`
+ : undefined,
+ });
+ });
+ });
+}
+
+function extractResultFilePaths(cwd: string, output: string): string[] {
+ const paths = new Set();
+ for (const line of output.split(/\r?\n/)) {
+ const match = line.match(ZG_RESULT_LINE_RE);
+ if (!match) continue;
+ const candidate = path.isAbsolute(match[1]!)
+ ? match[1]!
+ : path.resolve(cwd, match[1]!);
+ if (!paths.has(candidate) && fs.existsSync(candidate)) {
+ paths.add(candidate);
+ }
+ }
+ return [...paths];
+}
+
+function countResultMatches(output: string): number {
+ return output.split(/\r?\n/).filter((line) => ZG_RESULT_LINE_RE.test(line))
+ .length;
+}
+
+function formatSearchReturnDisplay(
+ output: string,
+ result: ZgCommandResult,
+): string {
+ const matchCount = countResultMatches(output);
+ if (matchCount === 0) {
+ return output.trim() ? 'Search completed' : 'No matches found';
+ }
+ const matchTerm = matchCount === 1 ? 'match' : 'matches';
+ return `Found ${matchCount} ${matchTerm}${result.truncated ? ' (truncated)' : ''}`;
+}
+
+function appendTruncationNotice(
+ output: string,
+ result: ZgCommandResult,
+): string {
+ if (!result.truncated) return output;
+ return [
+ output,
+ '---',
+ 'Output was truncated to keep the tool result bounded. Retry with a narrower query, paths, glob, or limit if more detail is needed.',
+ ].join('\n');
+}
+
+async function makeSearchSuccessResult(
+ config: Config,
+ cwd: string,
+ result: ZgCommandResult,
+): Promise {
+ const rawOutput = result.stdout.trim();
+ const content = appendTruncationNotice(
+ rawOutput || 'No matches found',
+ result,
+ );
+ const resultFilePaths = extractResultFilePaths(cwd, content);
+ await recordGrepResultFileReads(config, resultFilePaths);
+ return {
+ llmContent: content,
+ returnDisplay: formatSearchReturnDisplay(rawOutput, result),
+ resultFilePaths,
+ };
+}
+
+function makeErrorResult(
+ label: string,
+ commandArgs: readonly string[],
+ result: ZgCommandResult,
+): ToolResult {
+ const content = [
+ label,
+ '',
+ `command: ${formatZgCommand(commandArgs)}`,
+ `exit_code: ${result.code ?? 'unknown'}`,
+ result.stdout.trim() ? `stdout:\n${result.stdout.trim()}` : '',
+ result.stderr.trim() ? `stderr:\n${result.stderr.trim()}` : '',
+ result.error ? `error: ${result.error}` : '',
+ ]
+ .filter(Boolean)
+ .join('\n');
+ return { llmContent: content, returnDisplay: content };
+}
+
+function buildNativeGrepArgs(cwd: string, params: ZvecGrepParams): string[] {
+ const query = getSearchQuery(params) ?? '';
+ const pattern =
+ params.operation === 'semantic' ? buildSemanticFallbackQuery(query) : query;
+ const args = [
+ '--line-number',
+ '--with-filename',
+ '--no-heading',
+ '--color',
+ 'never',
+ ];
+
+ const searchPaths: string[] = [];
+ for (const scopePath of normalizeSearchPaths(params)) {
+ if (pathLooksLikeGlob(scopePath)) {
+ for (const expandedGlob of expandBraceAlternates(scopePath)) {
+ args.push('--glob', expandedGlob);
+ }
+ } else {
+ searchPaths.push(resolvePath(cwd, scopePath));
+ }
+ }
+ const glob = normalizeOptionalString(params.glob);
+ if (glob) {
+ for (const expandedGlob of expandBraceAlternates(glob)) {
+ args.push('--glob', expandedGlob);
+ }
+ }
+ for (const item of expandGlobs(normalizeStringArray(params.exclude))) {
+ args.push('--glob', item.startsWith('!') ? item : `!${item}`);
+ }
+
+ args.push(
+ '-e',
+ pattern,
+ '--',
+ ...(searchPaths.length > 0 ? searchPaths : [cwd]),
+ );
+ return args;
+}
+
+async function runNativeGrepSearch(
+ config: Config,
+ cwd: string,
+ params: ZvecGrepParams,
+ signal: AbortSignal,
+): Promise {
+ const args = buildNativeGrepArgs(cwd, params);
+ let result: RipgrepRunResult;
+ try {
+ result = await runRipgrep(args, signal, config.getUseBuiltinRipgrep());
+ } catch (error) {
+ const content = `Regular search failed.\n\nerror: ${getErrorMessage(error)}`;
+ return { llmContent: content, returnDisplay: content };
+ }
+ if (result.error && !result.stdout.trim()) {
+ const content = [
+ 'Regular search failed.',
+ '',
+ `command: ${['rg', ...args].map(shellQuoteForDisplay).join(' ')}`,
+ `error: ${result.error.message}`,
+ ].join('\n');
+ return { llmContent: content, returnDisplay: content };
+ }
+
+ const lines = result.stdout.split(/\r?\n/).filter(Boolean);
+ const limit =
+ params.operation === 'semantic'
+ ? (params.limit ?? DEFAULT_SEMANTIC_LIMIT)
+ : params.limit;
+ const limitedLines = limit === undefined ? lines : lines.slice(0, limit);
+ const truncated = result.truncated || limitedLines.length < lines.length;
+ return makeSearchSuccessResult(config, cwd, {
+ ok: true,
+ code: 0,
+ stdout: limitedLines.join('\n'),
+ stderr: '',
+ truncated,
+ });
+}
+
+async function runGrepSearch(
+ config: Config,
+ cwd: string,
+ args: readonly string[],
+ params: ZvecGrepParams,
+ signal: AbortSignal,
+): Promise {
+ const result = await runZg(args, cwd, signal, {
+ allowPartialOutput: true,
+ });
+ if (!result.ok) {
+ if (
+ result.error === 'aborted' ||
+ result.error?.startsWith('timed out after ')
+ ) {
+ return makeErrorResult('zvec-grep search failed.', args, result);
+ }
+ return runNativeGrepSearch(config, cwd, params, signal);
+ }
+ return makeSearchSuccessResult(config, cwd, result);
+}
+
+class ZvecGrepInvocation extends BaseToolInvocation<
+ ZvecGrepParams,
+ ToolResult
+> {
+ private setupPromptPromise?: Promise;
+ private setupApproved = false;
+ private setupNotice?: string;
+
+ constructor(
+ private readonly config: Config,
+ private readonly sessionState: ZvecGrepSessionState,
+ params: ZvecGrepParams,
+ ) {
+ super(params);
+ }
+
+ private getExternalPathScopes(): string[] {
+ const workspaceContext = this.config.getWorkspaceContext();
+ const targetDir = this.config.getTargetDir();
+ return normalizeSearchPaths(this.params).filter((scopePath) => {
+ const resolvedPath = resolvePath(targetDir, scopePath);
+ return (
+ !workspaceContext.isPathWithinWorkspace(resolvedPath) &&
+ !isSubpath(getMemoryBaseDir(), resolvedPath)
+ );
+ });
+ }
+
+ getDescription(): string {
+ const query = getSearchQuery(this.params);
+ if (this.params.operation === 'rg') {
+ return query ? `zvec-grep rg: ${query}` : 'zvec-grep rg';
+ }
+ return query
+ ? `Semantic zvec-grep search: ${query}`
+ : 'Semantic zvec-grep search';
+ }
+
+ private getSetupPromptState(): Promise {
+ if (
+ this.params.operation !== 'semantic' ||
+ this.sessionState.useNativeGrep ||
+ !this.config.isInteractive()
+ ) {
+ return Promise.resolve({ required: false, needsInstall: false });
+ }
+
+ if (!this.setupPromptPromise) {
+ const cwd = this.config.getTargetDir();
+ this.setupPromptPromise = runZg(
+ ['status'],
+ cwd,
+ new AbortController().signal,
+ ).then((status) => {
+ if (!status.ok) {
+ return {
+ required: canUseSemanticEmbedding(),
+ needsInstall: true,
+ };
+ }
+ const parsedStatus = parseStatus(`${status.stdout}\n${status.stderr}`);
+ const backgroundIndexRunning =
+ readBackgroundIndexJob(cwd) !== undefined;
+ return {
+ required:
+ parsedStatus.unindexed &&
+ !parsedStatus.ready &&
+ !parsedStatus.disabled &&
+ !parsedStatus.indexing &&
+ !backgroundIndexRunning &&
+ canUseSemanticEmbedding(),
+ needsInstall: false,
+ parsedStatus,
+ };
+ });
+ }
+ return this.setupPromptPromise;
+ }
+
+ private buildSetupQuestion(
+ setup: SetupPromptState,
+ externalScopes: readonly string[],
+ ): string {
+ const setupText = setup.needsInstall
+ ? `Qwen Code will install ${ZVEC_GREP_NPM_PACKAGE} globally with npm and build a semantic index for this workspace.`
+ : 'Qwen Code can build a semantic index for this workspace.';
+ const embeddingModel = getIndexEmbeddingModel();
+ const embeddingText = isLocalEmbeddingModel(embeddingModel)
+ ? 'Indexing uses a local embedding model, so workspace files stay local.'
+ : embeddingModel === DEFAULT_EMBEDDING_MODEL
+ ? `With the default ${DEFAULT_EMBEDDING_MODEL} model, workspace code fragments and semantic search queries are sent to the Qwen/DashScope embedding service.`
+ : `Indexing uses the configured ${embeddingModel} remote embedding model, which sends workspace code fragments and semantic search queries to its embedding service.`;
+ const externalText =
+ externalScopes.length > 0
+ ? [
+ 'This search also includes paths outside the current workspace:',
+ ...externalScopes.map((scopePath) => ` - ${scopePath}`),
+ ].join('\n')
+ : undefined;
+
+ return [
+ setupText,
+ 'Indexing runs in the background. Regular search remains available while the index is being built.',
+ embeddingText,
+ externalText,
+ ]
+ .filter((part): part is string => part !== undefined)
+ .join('\n\n');
+ }
+
+ private buildSetupConfirmation(
+ setup: SetupPromptState,
+ externalScopes: readonly string[],
+ ): ToolAskUserQuestionConfirmationDetails {
+ const options = [
+ {
+ label: ENABLE_WORKSPACE_CHOICE,
+ description:
+ 'Install zg if needed and build the index in the background.',
+ },
+ {
+ label: NOT_THIS_SESSION_CHOICE,
+ description:
+ 'Use regular search for this session and ask again in a future session.',
+ },
+ ];
+ if (this.config.canDisableZvecGrepForWorkspace()) {
+ options.push({
+ label: DISABLE_WORKSPACE_CHOICE,
+ description:
+ 'Do not install or index here. Always use regular search in this workspace.',
+ });
+ }
+
+ return {
+ type: 'ask_user_question',
+ title: 'Enable semantic search for this workspace?',
+ questions: [
+ {
+ header: 'Semantic search',
+ question: this.buildSetupQuestion(setup, externalScopes),
+ options,
+ allowCustomInput: false,
+ },
+ ],
+ onConfirm: async (
+ outcome: ToolConfirmationOutcome,
+ payload?: ToolConfirmationPayload,
+ ) => {
+ if (outcome === ToolConfirmationOutcome.Cancel) return;
+ const choice = payload?.answers?.['0'];
+ if (choice === ENABLE_WORKSPACE_CHOICE) {
+ this.setupApproved = true;
+ return;
+ }
+
+ this.sessionState.useNativeGrep = true;
+ if (choice === DISABLE_WORKSPACE_CHOICE) {
+ try {
+ await this.config.disableZvecGrepForWorkspace();
+ } catch (error) {
+ debugLogger.warn(
+ 'Failed to persist zvec-grep workspace disable',
+ error,
+ );
+ this.setupNotice =
+ 'Could not save the workspace setting; enhanced search is disabled for this session only.';
+ }
+ }
+ },
+ };
+ }
+
+ private withSetupNotice(result: ToolResult): ToolResult {
+ if (!this.setupNotice) return result;
+ const display =
+ typeof result.returnDisplay === 'string'
+ ? result.returnDisplay
+ : 'Regular search completed';
+ return {
+ ...result,
+ returnDisplay: `${this.setupNotice}\n${display}`,
+ };
+ }
+
+ private async runNativeGrep(
+ cwd: string,
+ signal: AbortSignal,
+ ): Promise {
+ return this.withSetupNotice(
+ await runNativeGrepSearch(this.config, cwd, this.params, signal),
+ );
+ }
+
+ override async getDefaultPermission(): Promise {
+ if (this.getExternalPathScopes().length > 0) return 'ask';
+ const setup = await this.getSetupPromptState();
+ return setup.required ? 'ask' : 'allow';
+ }
+
+ override async getConfirmationDetails(
+ abortSignal: AbortSignal,
+ ): Promise {
+ const externalScopes = this.getExternalPathScopes();
+ const setup = await this.getSetupPromptState();
+ if (setup.required) {
+ return this.buildSetupConfirmation(setup, externalScopes);
+ }
+ if (externalScopes.length === 0) {
+ return super.getConfirmationDetails(abortSignal);
+ }
+ return {
+ type: 'info',
+ title: 'Confirm zvec-grep external path search',
+ prompt: [
+ 'zvec-grep was asked to search outside the current workspace.',
+ '',
+ 'External paths:',
+ ...externalScopes.map((scopePath) => ` - ${scopePath}`),
+ ].join('\n'),
+ onConfirm: async () => {},
+ };
+ }
+
+ async execute(
+ signal: AbortSignal,
+ updateOutput?: (output: ToolResultDisplay) => void,
+ ): Promise {
+ const cwd = this.config.getTargetDir();
+
+ if (this.sessionState.useNativeGrep) {
+ updateOutput?.('Searching workspace');
+ return this.runNativeGrep(cwd, signal);
+ }
+
+ if (this.params.operation === 'rg') {
+ const grepArgs = buildGrepArgs(this.params);
+ updateOutput?.(
+ `Searching with zvec-grep rg: ${formatZgCommand(grepArgs)}`,
+ );
+ return runGrepSearch(this.config, cwd, grepArgs, this.params, signal);
+ }
+
+ let status = await runZg(['status'], cwd, signal);
+ if (!status.ok && this.setupApproved && status.error !== 'aborted') {
+ updateOutput?.('Installing enhanced search support');
+ const installResult = await installZvecGrep(signal);
+ if (!installResult.ok) {
+ debugLogger.warn(
+ 'Failed to install zvec-grep after user approval',
+ installResult.error ||
+ installResult.stderr.trim() ||
+ installResult.stdout.trim(),
+ );
+ this.setupNotice =
+ 'Enhanced search setup failed; regular search was used.';
+ return this.runNativeGrep(cwd, signal);
+ }
+ status = await runZg(['status'], cwd, signal);
+ }
+ if (!status.ok) {
+ if (this.setupApproved) {
+ this.setupNotice =
+ 'Enhanced search setup did not become available; regular search was used.';
+ }
+ return this.runNativeGrep(cwd, signal);
+ }
+
+ const parsed = parseStatus(`${status.stdout}\n${status.stderr}`);
+ const canRunSemantic =
+ status.ok && parsed.ready && canUseSemanticEmbedding(parsed);
+
+ if (canRunSemantic) {
+ const searchArgs = buildSearchArgs(this.params);
+ updateOutput?.('Searching with zvec-grep');
+ const searchResult = await runZg(searchArgs, cwd, signal, {
+ allowPartialOutput: true,
+ });
+ if (searchResult.ok) {
+ return makeSearchSuccessResult(this.config, cwd, searchResult);
+ }
+ debugLogger.debug(
+ 'zvec-grep semantic search failed; falling back to rg',
+ searchResult.error ||
+ searchResult.stderr.trim() ||
+ searchResult.stdout.trim(),
+ );
+ } else if (this.setupApproved) {
+ this.setupNotice = startApprovedBackgroundIndex(cwd, parsed)
+ ? 'Semantic indexing is running in the background; regular search was used for this request.'
+ : 'Semantic indexing could not be started; regular search was used.';
+ }
+
+ const grepArgs = buildSemanticFallbackGrepArgs(this.params);
+ updateOutput?.('Searching with zvec-grep');
+ return this.withSetupNotice(
+ await runGrepSearch(this.config, cwd, grepArgs, this.params, signal),
+ );
+ }
+}
+
+export class ZvecGrepTool extends BaseDeclarativeTool<
+ ZvecGrepParams,
+ ToolResult
+> {
+ static readonly Name = ToolNames.ZVEC_GREP;
+ private readonly sessionState: ZvecGrepSessionState = {
+ useNativeGrep: false,
+ };
+
+ override get maxOutputChars(): number {
+ return 20_000;
+ }
+
+ constructor(private readonly config: Config) {
+ super(
+ ZvecGrepTool.Name,
+ ToolDisplayNames.ZVEC_GREP,
+ [
+ 'Search workspace content with semantic discovery or exact ripgrep-compatible matching.',
+ '',
+ 'Use operation="semantic" with query for semantic or fuzzy discovery: concepts, behavior, architecture, relationships, topics, relevant files, or cases where exact keywords are unknown.',
+ '',
+ 'Use operation="rg" with pattern for exact text or regular-expression searches: names, paths, messages, literals, config keys, documentation phrases, and other known text patterns.',
+ '',
+ 'Treat returned files, symbols, and line ranges as candidates to inspect. Read the relevant ranges, and increase limit or refine the query when results are too narrow.',
+ ].join('\n'),
+ Kind.Search,
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ operation: {
+ type: 'string',
+ enum: ['semantic', 'rg'],
+ description:
+ 'Search mode. Use semantic for fuzzy or meaning-based workspace search. Use rg for exact text or regular-expression search.',
+ },
+ query: {
+ type: 'string',
+ description:
+ 'Natural-language search query for operation="semantic".',
+ },
+ pattern: {
+ type: 'string',
+ description:
+ 'Exact text or regular expression pattern for operation="rg".',
+ },
+ path: {
+ type: 'string',
+ description:
+ 'Optional file or directory to search in. Defaults to the current workspace.',
+ },
+ paths: {
+ type: 'array',
+ items: { type: 'string' },
+ description:
+ 'Optional file or directory paths selected by the agent to narrow the current search.',
+ },
+ glob: {
+ type: 'string',
+ description:
+ 'Optional glob filter for files, e.g. "**/*.{ts,tsx}" or "src/**".',
+ },
+ exclude: {
+ type: 'array',
+ items: { type: 'string' },
+ description:
+ 'Optional exclude globs or paths, e.g. ["build/**", "thirdparty/**", "node_modules/**"].',
+ },
+ limit: {
+ type: 'integer',
+ minimum: 1,
+ description:
+ 'Maximum returned results. Semantic search defaults to 20 when omitted. For operation="rg", omit this unless the user explicitly asks for a capped sample.',
+ },
+ },
+ required: ['operation'],
+ },
+ true,
+ true,
+ );
+ }
+
+ override validateToolParams(params: ZvecGrepParams): string | null {
+ return (
+ validateRawStringArrayField(params, 'paths') ??
+ validateRawStringArrayField(params, 'exclude') ??
+ super.validateToolParams(params)
+ );
+ }
+
+ protected override validateToolParamValues(
+ params: ZvecGrepParams,
+ ): string | null {
+ if (!['semantic', 'rg'].includes(params.operation)) {
+ return 'operation must be one of: semantic, rg';
+ }
+ if (!getSearchQuery(params)) {
+ return `query or pattern must be a non-empty string for operation="${params.operation}"`;
+ }
+ if (params.query !== undefined && !params.query.trim()) {
+ return 'query must be a non-empty string when provided';
+ }
+ if (params.pattern !== undefined && !params.pattern.trim()) {
+ return 'pattern must be a non-empty string when provided';
+ }
+ if (params.path !== undefined && !params.path.trim()) {
+ return 'path must be a non-empty string when provided';
+ }
+ if (
+ params.limit !== undefined &&
+ (!Number.isInteger(params.limit) || params.limit <= 0)
+ ) {
+ return 'limit must be a positive integer';
+ }
+ if (params.glob !== undefined && !params.glob.trim()) {
+ return 'glob must be a non-empty string when provided';
+ }
+ for (const field of ['paths', 'exclude'] as const) {
+ const value = params[field];
+ if (
+ value !== undefined &&
+ value.some((item) => typeof item !== 'string' || !item.trim())
+ ) {
+ return `${field} must contain only non-empty strings`;
+ }
+ }
+ return null;
+ }
+
+ protected createInvocation(
+ params: ZvecGrepParams,
+ ): ToolInvocation {
+ return new ZvecGrepInvocation(this.config, this.sessionState, params);
+ }
+}
diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json
index b30c393bf22..2b093305e7e 100644
--- a/packages/vscode-ide-companion/schemas/settings.schema.json
+++ b/packages/vscode-ide-companion/schemas/settings.schema.json
@@ -1187,6 +1187,17 @@
}
}
},
+ "zvecGrep": {
+ "description": "Settings for the zvec-grep built-in search tool.",
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "description": "When enabled, registers the zvec_grep built-in tool. Disabled by default.",
+ "type": "boolean",
+ "default": false
+ }
+ }
+ },
"shell": {
"description": "Settings for shell execution.",
"type": "object",
diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts
index 96dfa0b414d..a667d9bbf6b 100644
--- a/packages/web-shell/client/components/messages/toolFormatting.ts
+++ b/packages/web-shell/client/components/messages/toolFormatting.ts
@@ -18,6 +18,7 @@ export const TOOL_DISPLAY_NAMES: Record = {
zoom_image: 'ZoomImage',
grep: 'Grep',
grep_search: 'Grep',
+ zvec_grep: 'ZvecGrep',
glob: 'Glob',
run_shell_command: 'Shell',
todo_write: 'TodoList',
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx
index 7c7f1afb346..a20f63a06fa 100644
--- a/packages/web-shell/client/i18n.tsx
+++ b/packages/web-shell/client/i18n.tsx
@@ -2924,6 +2924,7 @@ const ZH: Messages = {
'toolName.zoom_image': '图片放大',
'toolName.grep': '搜索内容',
'toolName.grep_search': '搜索内容',
+ 'toolName.zvec_grep': '语义搜索',
'toolName.glob': 'Glob',
'toolName.run_shell_command': '运行命令',
'toolName.todo_write': '任务清单',