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': '任务清单',