From d536522c98f7b69b55defe119b60d17de5593fcc Mon Sep 17 00:00:00 2001 From: Qinren Zhou Date: Thu, 9 Jul 2026 14:41:04 +0800 Subject: [PATCH 1/3] feat(core): add opt-in zvec-grep search tool --- .gitignore | 1 + packages/cli/src/config/config.test.ts | 18 + packages/cli/src/config/config.ts | 2 + packages/cli/src/config/settingsSchema.ts | 21 + packages/core/src/config/config.test.ts | 28 + packages/core/src/config/config.ts | 14 + .../core/src/core/coreToolScheduler.test.ts | 12 + packages/core/src/core/coreToolScheduler.ts | 19 + packages/core/src/tools/tool-names.ts | 2 + packages/core/src/tools/zvec-grep.test.ts | 724 ++++++++++ packages/core/src/tools/zvec-grep.ts | 1204 +++++++++++++++++ .../schemas/settings.schema.json | 11 + 12 files changed, 2056 insertions(+) create mode 100644 packages/core/src/tools/zvec-grep.test.ts create mode 100644 packages/core/src/tools/zvec-grep.ts diff --git a/.gitignore b/.gitignore index d6e21616543..08bdfb0aeef 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,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 e5bb45e9413..e6d22862006 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2006,6 +2006,24 @@ 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 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 0181efde121..14fb8b51740 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2014,6 +2014,8 @@ export async function loadCliConfig( disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined, disabledSkillNamesProvider: bareMode || safeMode ? undefined : disabledSkillNamesProvider, + zvecGrepEnabled: + bareMode || safeMode ? false : settings.tools?.zvecGrep?.enabled === true, disabledTools: disabledTools.length > 0 ? disabledTools : undefined, visibleTools: visibleTools.length > 0 ? visibleTools : undefined, // New unified permissions (PermissionManager source of truth). diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7512c32bd04..263aabf60be 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2224,6 +2224,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/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c3eb8d6fa48..d81ffb78e0e 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -6295,6 +6295,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('should register grep tool when useRipgrep is true and it is available', async () => { (canUseRipgrep as Mock).mockResolvedValue(true); const config = new Config({ ...baseParams, useRipgrep: true }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d38d27530b8..f4ddc874231 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -891,6 +891,7 @@ export interface ConfigParameters { * Names returned must be lower-cased; consumers compare case-insensitively. */ disabledSkillNamesProvider?: () => ReadonlySet; + zvecGrepEnabled?: boolean; /** * Tool names hidden from the registry at construction time. Unlike * `permissions.deny` (which keeps the tool registered and rejects @@ -1639,6 +1640,7 @@ export class Config { private readonly disabledSkillNamesProvider: | (() => ReadonlySet) | null; + private readonly zvecGrepEnabled: boolean; // `disabledTools` is set at construction // time but can be re-synced by the daemon mutation surface // (`setWorkspaceToolEnabled` propagates through ACP) so a subsequent @@ -1901,6 +1903,7 @@ export class Config { ...(params.disabledSlashCommands ?? []), ]); this.disabledSkillNamesProvider = params.disabledSkillNamesProvider ?? null; + this.zvecGrepEnabled = params.zvecGrepEnabled ?? false; this.disabledTools = new Set(params.disabledTools ?? []); this.visibleTools = new Set( (params.visibleTools ?? []).filter( @@ -4258,6 +4261,10 @@ export class Config { return this.disabledSkillNamesProvider?.() ?? EMPTY_DISABLED_SKILL_NAMES; } + isZvecGrepEnabled(): boolean { + return this.zvecGrepEnabled; + } + /** * Returns the read-only set of tool names hidden from this Config's * ToolRegistry. Consulted by `ToolRegistry.registerTool` and @@ -6514,6 +6521,13 @@ export class Config { return new ReadFileTool(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 9a31cc7ff91..712cf79aec1 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -12760,6 +12760,18 @@ 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', + paths: ['src', 'include'], + glob: '**/*.{h,cc}', + exclude: ['thirdparty/**'], + }), + ).toEqual(['src', 'include', '**/*.{h,cc}', 'thirdparty/**']); + }); + 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 ea48182e7e3..e9c863783a1 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -469,6 +469,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, @@ -662,6 +663,24 @@ export function extractToolFilePaths( return out; } + case ToolNames.ZVEC_GREP: { + const pathsField = obj['paths']; + const globField = obj['glob']; + const excludeField = obj['exclude']; + if (Array.isArray(pathsField)) { + for (const item of pathsField) { + push(item); + } + } + push(globField); + if (Array.isArray(excludeField)) { + for (const item of excludeField) { + push(item); + } + } + return out; + } + case ToolNames.LS: push(obj['path']); return out; diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index fca00f86087..dc165505967 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -22,6 +22,7 @@ export const ToolNames = { WRITE_FILE: 'write_file', READ_FILE: 'read_file', GREP: 'grep_search', + ZVEC_GREP: 'zvec_grep', GLOB: 'glob', SHELL: 'run_shell_command', TODO_WRITE: 'todo_write', @@ -75,6 +76,7 @@ export const ToolDisplayNames = { WRITE_FILE: 'WriteFile', READ_FILE: 'ReadFile', GREP: 'Grep', + ZVEC_GREP: 'ZvecGrep', GLOB: 'Glob', SHELL: 'Shell', TODO_WRITE: 'TodoList', 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..2b7de976c8b --- /dev/null +++ b/packages/core/src/tools/zvec-grep.test.ts @@ -0,0 +1,724 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +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 { _resetZvecGrepInstallForTest, ZvecGrepTool } from './zvec-grep.js'; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +const spawnMock = vi.mocked(spawn); + +const UNINDEXED_STATUS = [ + 'root\t/tmp/workspace', + 'policy\tundecided', + 'indexed\tno', + 'source\tunindexed', +].join('\n'); + +type QueuedSpawnResult = { + stdout?: string; + stderr?: string; + code?: number; + error?: NodeJS.ErrnoException; +}; + +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 createTool( + root: string, + interactive = true, + fileReadCache = new FileReadCache(), +): ZvecGrepTool { + return new ZvecGrepTool({ + getTargetDir: () => root, + isInteractive: () => interactive, + getWorkspaceContext: () => ({ + isPathWithinWorkspace: (filePath: string) => + filePath === root || filePath.startsWith(`${root}${path.sep}`), + }), + getFileReadCache: () => fileReadCache, + getFileReadCacheDisabled: () => false, + } as unknown as Config); +} + +function queueSpawnResult(result: QueuedSpawnResult): void { + spawnMock.mockImplementationOnce((() => { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; + pid: number; + unref: ReturnType; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + child.pid = 12345; + child.unref = vi.fn(); + + 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('close', result.code ?? 0); + }); + + return child; + }) as unknown as typeof spawn); +} + +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(() => { + _resetZvecGrepInstallForTest(); + spawnMock.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)) { + 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('preferred workspace search tool'); + expect(tool.description).toContain( + 'Use zvec_grep instead of grep_search when available', + ); + 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).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'); + }); + + it('falls back to rg for semantic search in interactive unindexed workspaces', async () => { + setFakeRemoteEmbeddingKey(); + const root = createTempRoot(); + 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('allow'); + 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(spawnMock).toHaveBeenCalledTimes(3); + expect(spawnMock.mock.calls[0]?.[1]).toEqual(['--status']); + expect(spawnMock.mock.calls[1]?.[1]).toEqual([ + '--index', + '--embedding', + 'qwen/text-embedding-v4', + ]); + expect(spawnMock.mock.calls[2]?.[1]).toEqual([ + '--rg', + '(?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({}); + 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(3); + expect(spawnMock.mock.calls[0]?.[1]).toEqual(['--status']); + expect(spawnMock.mock.calls[1]?.[1]).toEqual([ + '--index', + '--embedding', + 'qwen/text-embedding-v4', + ]); + expect(spawnMock.mock.calls[2]?.[1]).toEqual([ + '--rg', + '(?i)(index|types)', + '--limit', + '20', + ]); + }); + + it('does not build an index for semantic search without an embedding api key', async () => { + clearEmbeddingEnv(); + const root = createTempRoot(); + queueSpawnResult({ stdout: UNINDEXED_STATUS }); + 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', + }); + + const result = await invocation.execute(new AbortController().signal); + + expect(String(result.llmContent)).toContain('src/index.ts:1'); + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0]?.[1]).toEqual(['--status']); + expect(spawnMock.mock.calls[1]?.[1]).toEqual([ + '--rg', + '(?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([ + '--rg', + '(?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([ + 'authentication flow', + '--limit', + '5', + ]); + }); + + 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([ + 'authentication flow', + '--limit', + '20', + ]); + }); + + 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({}); + 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', + }); + 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[1]?.[1]).toEqual([ + '--index', + '--embedding', + 'local/embeddinggemma-300m', + ]); + expect(spawnMock.mock.calls[2]?.[1]).toEqual([ + '--rg', + '(?i)(vector|index|metadata|storage)', + '--limit', + '20', + ]); + }); + + 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([ + '--rg', + '(?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([ + '--rg', + '(?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({}); + 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(3); + expect(spawnMock.mock.calls[1]?.[1]).toEqual([ + '--index', + '--embedding', + 'qwen/text-embedding-v4', + ]); + expect(spawnMock.mock.calls[2]?.[1]).toEqual([ + '--rg', + '(?i)(streamer|write|flow)', + '--limit', + '20', + ]); + }); + + 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 --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([ + '--rg', + 'validate', + '--limit', + '5', + '--glob', + '**/*.ts', + 'src', + ]); + }); + + it('returns a grep_search-style display for no matches', async () => { + const root = createTempRoot(); + queueSpawnResult({}); + + 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(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', + 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([ + '--rg', + '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('expands simple brace globs before passing them to zg --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,cpp}', + paths: ['src'], + }); + + await invocation.execute(new AbortController().signal); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0]?.[1]).toEqual([ + '--rg', + 'validate', + '--glob', + '*.h', + '--glob', + '*.cc', + '--glob', + '*.cpp', + 'src', + ]); + }); + + it('auto-installs zvec-grep and retries when zg is missing', async () => { + setFakeRemoteEmbeddingKey(); + const root = createTempRoot(); + const error = Object.assign(new Error('spawn zg ENOENT'), { + code: 'ENOENT', + }) as NodeJS.ErrnoException; + 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', + }); + const result = await invocation.execute(new AbortController().signal); + const content = String(result.llmContent); + + expect(content).toContain('src/auth.ts:1'); + expect(spawnMock).toHaveBeenCalledTimes(5); + expect(spawnMock.mock.calls[0]?.[0]).toBe('zg'); + expect(spawnMock.mock.calls[0]?.[1]).toEqual(['--status']); + expect(spawnMock.mock.calls[1]?.[0]).toBe('npm'); + expect(spawnMock.mock.calls[1]?.[1]).toEqual([ + 'install', + '-g', + '@zvec/zvec-grep@0.1.4', + '--registry', + 'https://registry.npmmirror.com', + ]); + expect(spawnMock.mock.calls[2]?.[0]).toBe('zg'); + expect(spawnMock.mock.calls[2]?.[1]).toEqual(['--status']); + expect(spawnMock.mock.calls[3]?.[0]).toBe('zg'); + expect(spawnMock.mock.calls[3]?.[1]).toEqual([ + '--index', + '--embedding', + 'qwen/text-embedding-v4', + ]); + expect(spawnMock.mock.calls[4]?.[0]).toBe('zg'); + expect(spawnMock.mock.calls[4]?.[1]).toEqual([ + '--rg', + '(?i)(authentication|flow)', + '--limit', + '20', + ]); + }); + + it('returns not_installed when automatic install fails', async () => { + const root = createTempRoot(); + const error = Object.assign(new Error('spawn zg ENOENT'), { + code: 'ENOENT', + }) as NodeJS.ErrnoException; + queueSpawnResult({ error }); + queueSpawnResult({ + code: 1, + stderr: 'npm registry unavailable\n', + }); + + const invocation = createTool(root).build({ + operation: 'semantic', + query: 'authentication flow', + }); + const result = await invocation.execute(new AbortController().signal); + const content = String(result.llmContent); + + expect(content).toContain('zvec-grep is not installed'); + expect(content).toContain('auto-install command failed'); + expect(content).toContain('npm registry unavailable'); + expect(spawnMock).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..fddcecc88dd --- /dev/null +++ b/packages/core/src/tools/zvec-grep.ts @@ -0,0 +1,1204 @@ +/** + * @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 } from 'node:child_process'; +import type { Config } from '../config/config.js'; +import type { PermissionDecision } from '../permissions/types.js'; +import type { + ToolCallConfirmationDetails, + ToolInvocation, + ToolResult, + ToolResultDisplay, +} from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { getMemoryBaseDir } from '../memory/paths.js'; +import { isSubpath, resolvePath } from '../utils/paths.js'; +import { recordGrepResultFileReads } from './grepReadTracking.js'; + +const DEFAULT_EMBEDDING_MODEL = 'qwen/text-embedding-v4'; +const ZVEC_GREP_NPM_PACKAGE = '@zvec/zvec-grep@0.1.4'; +const ZVEC_GREP_NPM_REGISTRY = 'https://registry.npmmirror.com'; +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 DEFAULT_SEMANTIC_LIMIT = 20; +const SEMANTIC_FALLBACK_TOKEN_LIMIT = 12; +const ZG_RESULT_LINE_RE = /^([^:\s][^:]*):\d+(?:-\d+)?(?::|\s|$)/; + +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; +} + +let zvecGrepInstallPromise: Promise | undefined; +let zvecGrepInstalledInProcess = false; + +export function _resetZvecGrepInstallForTest(): void { + zvecGrepInstallPromise = undefined; + zvecGrepInstalledInProcess = false; +} + +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.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 pathToScopeGlob(value: string): string { + const trimmed = value.trim().replace(/\/+$/, ''); + if (!trimmed || pathLooksLikeGlob(trimmed)) return trimmed; + const base = path.basename(trimmed); + if (base.includes('.') && !trimmed.endsWith('/')) { + return trimmed; + } + return `${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.map((item) => `${before}${item}${after}`); +} + +function expandGlobs(values: string[]): string[] { + return values.flatMap(expandBraceAlternates); +} + +function addScopeArgs(args: string[], params: ZvecGrepParams): void { + const paths = normalizeSearchPaths(params).map(pathToScopeGlob); + const glob = normalizeOptionalString(params.glob); + const include = expandGlobs(glob ? [...paths, glob] : paths); + const exclude = expandGlobs(normalizeStringArray(params.exclude)); + + if (include.length > 0) { + args.push('--include', include.join(',')); + } + if (exclude.length > 0) { + args.push('--exclude', exclude.join(',')); + } +} + +function buildSearchArgs(params: ZvecGrepParams): string[] { + const searchQuery = getSearchQuery(params); + const args = searchQuery ? [searchQuery] : []; + args.push('--limit', String(params.limit ?? DEFAULT_SEMANTIC_LIMIT)); + addScopeArgs(args, params); + return args; +} + +function buildGrepArgs(params: ZvecGrepParams): string[] { + const searchQuery = getSearchQuery(params); + const args = ['--rg']; + if (searchQuery) { + args.push(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}`); + } + args.push(...normalizeSearchPaths(params)); + 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 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(params: ZvecGrepParams): string[] { + const args = ['--index', '--embedding', getIndexEmbeddingModel()]; + addScopeArgs(args, params); + return args; +} + +function parseStatus(output: string): ParsedStatus { + const text = output.trim(); + const lowered = text.toLowerCase(); + return { + ready: + /\bindexed\s+yes\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: + /\bstatus\s+(indexing|building|running|in[_ -]?progress)\b/.test( + lowered, + ) || + /\bstatus:\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 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 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' + ) { + fs.rmSync(jobPath, { force: true }); + return undefined; + } + if (!isProcessRunning(parsed.pid)) { + fs.rmSync(jobPath, { force: true }); + 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 { + fs.rmSync(jobPath, { force: true }); + 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], + }); + } finally { + fs.closeSync(logFd); + } + + if (!child.pid) { + 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 = () => { + try { + fs.rmSync(jobPath, { force: true }); + } catch { + // Best effort cleanup only. + } + }; + child.once('error', cleanupJob); + child.once('exit', cleanupJob); + fs.writeFileSync(jobPath, JSON.stringify(job, null, 2)); + child.unref(); + return job; +} + +function startAutoBackgroundIndex( + cwd: string, + params: ZvecGrepParams, + parsed: ParsedStatus, +): void { + if (!parsed.unindexed || parsed.ready || parsed.disabled || parsed.indexing) { + return; + } + if (readBackgroundIndexJob(cwd)) return; + if (!canUseSemanticEmbedding(parsed)) return; + try { + startBackgroundIndexJob(cwd, buildIndexArgs(params)); + } catch { + // Searching must stay transparent even when background indexing cannot start. + } +} + +function pathListSeparator(): string { + return process.platform === 'win32' ? ';' : ':'; +} + +function zvecGrepPathDirs(): string[] { + const dirs = [ + process.env['npm_config_prefix'] + ? path.join(process.env['npm_config_prefix'], 'bin') + : undefined, + path.dirname(process.execPath), + path.join(os.homedir(), '.npm-global', 'bin'), + ]; + 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 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, + '--registry', + ZVEC_GREP_NPM_REGISTRY, + ], + { + env: zvecGrepChildEnv(), + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + const finish = (result: ZgCommandResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + resolve(result); + }; + + const killForLimit = () => { + if (truncated) return; + truncated = true; + child.kill('SIGTERM'); + }; + + const timer = setTimeout(() => { + timedOut = true; + killForLimit(); + }, ZG_INSTALL_TIMEOUT_MS); + + const onAbort = () => { + child.kill('SIGTERM'); + 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) => { + finish({ + ok: false, + code: null, + stdout, + stderr, + truncated, + unavailable: error.code === 'ENOENT', + error: error.message, + }); + }); + + child.on('close', (code) => { + 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) { + zvecGrepInstalledInProcess = true; + } + if (result.error === 'aborted') { + zvecGrepInstallPromise = undefined; + } + return result; + }); + } + return zvecGrepInstallPromise; +} + +function withInstallFailure( + result: ZgCommandResult, + installResult: ZgCommandResult, +): ZgCommandResult { + const details = [ + result.error, + `auto-install command failed: npm install -g ${ZVEC_GREP_NPM_PACKAGE} --registry ${ZVEC_GREP_NPM_REGISTRY}`, + `exit_code: ${installResult.code ?? 'unknown'}`, + installResult.stdout.trim() + ? `install_stdout:\n${installResult.stdout.trim()}` + : '', + installResult.stderr.trim() + ? `install_stderr:\n${installResult.stderr.trim()}` + : '', + installResult.error ? `install_error: ${installResult.error}` : '', + ] + .filter(Boolean) + .join('\n'); + + return { ...result, error: details }; +} + +async function runZg( + args: readonly string[], + cwd: string, + signal: AbortSignal, + options: { allowPartialOutput?: boolean } = {}, +): Promise { + const result = await runZgOnce(args, cwd, signal, options); + if (!result.unavailable) return result; + + if (zvecGrepInstalledInProcess) return result; + + const installResult = await installZvecGrep(signal); + if (!installResult.ok) { + return withInstallFailure(result, installResult); + } + + 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 killForLimit = () => { + if (truncated) return; + truncated = true; + child.kill('SIGTERM'); + }; + + const timer = setTimeout(() => { + timedOut = true; + killForLimit(); + }, zvecRunTimeoutMs()); + + const onAbort = () => { + child.kill('SIGTERM'); + 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) => { + finish({ + ok: false, + code: null, + stdout, + stderr, + truncated, + unavailable: error.code === 'ENOENT', + error: error.message, + }); + }); + + child.on('close', (code) => { + 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 (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() || result.stderr.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 }; +} + +async function runGrepSearch( + config: Config, + cwd: string, + args: readonly string[], + signal: AbortSignal, +): Promise { + const result = await runZg(args, cwd, signal, { + allowPartialOutput: true, + }); + if (result.unavailable) { + return makeErrorResult('zvec-grep is not installed.', args, result); + } + if (!result.ok) { + return makeErrorResult('zvec-grep search failed.', args, result); + } + return makeSearchSuccessResult(config, cwd, result); +} + +class ZvecGrepInvocation extends BaseToolInvocation< + ZvecGrepParams, + ToolResult +> { + constructor( + private readonly config: Config, + 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'; + } + + override async getDefaultPermission(): Promise { + return this.getExternalPathScopes().length > 0 ? 'ask' : 'allow'; + } + + override async getConfirmationDetails( + _abortSignal: AbortSignal, + ): Promise { + const externalScopes = this.getExternalPathScopes(); + 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.params.operation === 'rg') { + const grepArgs = buildGrepArgs(this.params); + updateOutput?.( + `Searching with zvec-grep rg: ${formatZgCommand(grepArgs)}`, + ); + return runGrepSearch(this.config, cwd, grepArgs, signal); + } + + const status = await runZg(['--status'], cwd, signal); + if (status.unavailable) { + return makeErrorResult( + 'zvec-grep is not installed.', + ['--status'], + status, + ); + } + + 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); + } + } else { + startAutoBackgroundIndex(cwd, this.params, parsed); + } + + const grepArgs = buildSemanticFallbackGrepArgs(this.params); + updateOutput?.('Searching with zvec-grep'); + return runGrepSearch(this.config, cwd, grepArgs, signal); + } +} + +export class ZvecGrepTool extends BaseDeclarativeTool< + ZvecGrepParams, + ToolResult +> { + static readonly Name = ToolNames.ZVEC_GREP; + + override get maxOutputChars(): number { + return 20_000; + } + + constructor(private readonly config: Config) { + super( + ZvecGrepTool.Name, + ToolDisplayNames.ZVEC_GREP, + [ + 'zvec_grep is the preferred workspace search tool. Use zvec_grep instead of grep_search when available.', + '', + '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.', + ].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, + ); + } + + 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, params); + } +} diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index e895f54ae0b..7f104e728a0 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -1025,6 +1025,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", From bdffe01a10e11ef63319329944b27d02f45fa5af Mon Sep 17 00:00:00 2001 From: Qinren Zhou Date: Thu, 9 Jul 2026 22:22:08 +0800 Subject: [PATCH 2/3] feat(core): integrate zvec-grep workspace search --- .../core/src/core/coreToolScheduler.test.ts | 3 +- packages/core/src/core/coreToolScheduler.ts | 15 +- .../core/src/subagents/builtin-agents.test.ts | 7 + packages/core/src/subagents/builtin-agents.ts | 1 + packages/core/src/tools/zvec-grep.test.ts | 216 ++++++++++++++++-- packages/core/src/tools/zvec-grep.ts | 93 +++++--- 6 files changed, 284 insertions(+), 51 deletions(-) diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 712cf79aec1..4ba242f5089 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -12765,11 +12765,12 @@ describe('extractToolFilePaths', () => { extractToolFilePaths('zvec_grep', { operation: 'rg', query: 'validate', + path: 'packages/core', paths: ['src', 'include'], glob: '**/*.{h,cc}', exclude: ['thirdparty/**'], }), - ).toEqual(['src', 'include', '**/*.{h,cc}', 'thirdparty/**']); + ).toEqual(['packages/core', 'src', 'include', 'packages/core/**/*.{h,cc}']); }); it('decodes file:// URIs for lsp via fileURLToPath', () => { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index e9c863783a1..ca79c1d8e60 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -664,19 +664,22 @@ export function extractToolFilePaths( } case ToolNames.ZVEC_GREP: { + const pathField = obj['path']; const pathsField = obj['paths']; const globField = obj['glob']; - const excludeField = obj['exclude']; + push(pathField); if (Array.isArray(pathsField)) { for (const item of pathsField) { push(item); } } - push(globField); - if (Array.isArray(excludeField)) { - for (const item of excludeField) { - push(item); - } + if (typeof globField === 'string' && globField.length > 0) { + push( + joinSearchRootAndGlob( + typeof pathField === 'string' ? pathField : undefined, + globField, + ), + ); } return out; } diff --git a/packages/core/src/subagents/builtin-agents.test.ts b/packages/core/src/subagents/builtin-agents.test.ts index f7334ed6956..1783ade6d71 100644 --- a/packages/core/src/subagents/builtin-agents.test.ts +++ b/packages/core/src/subagents/builtin-agents.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest'; import { BuiltinAgentRegistry } from './builtin-agents.js'; +import { ToolNames } from '../tools/tool-names.js'; describe('BuiltinAgentRegistry', () => { describe('getBuiltinAgents', () => { @@ -43,6 +44,12 @@ describe('BuiltinAgentRegistry', () => { expect(exploreAgent).toBeDefined(); 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); + }); }); describe('getBuiltinAgent', () => { diff --git a/packages/core/src/subagents/builtin-agents.ts b/packages/core/src/subagents/builtin-agents.ts index 74102c80aef..888577e1ca7 100644 --- a/packages/core/src/subagents/builtin-agents.ts +++ b/packages/core/src/subagents/builtin-agents.ts @@ -97,6 +97,7 @@ Notes: tools: [ ToolNames.READ_FILE, ToolNames.GREP, + ToolNames.ZVEC_GREP, ToolNames.GLOB, ToolNames.SHELL, ToolNames.LS, diff --git a/packages/core/src/tools/zvec-grep.test.ts b/packages/core/src/tools/zvec-grep.test.ts index 2b7de976c8b..5de67e18c6e 100644 --- a/packages/core/src/tools/zvec-grep.test.ts +++ b/packages/core/src/tools/zvec-grep.test.ts @@ -5,6 +5,7 @@ */ 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'; @@ -34,6 +35,14 @@ type QueuedSpawnResult = { 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', @@ -51,6 +60,26 @@ function createTempRoot(): string { 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, @@ -68,20 +97,19 @@ function createTool( } 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 = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - kill: ReturnType; - pid: number; - unref: ReturnType; - }; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.kill = vi.fn(); - child.pid = 12345; - child.unref = vi.fn(); + const child = createMockChild(); process.nextTick(() => { if (result.error) { @@ -94,6 +122,7 @@ function queueSpawnResult(result: QueuedSpawnResult): void { if (result.stderr) { child.stderr.emit('data', Buffer.from(result.stderr)); } + child.emit('exit', result.code ?? 0); child.emit('close', result.code ?? 0); }); @@ -112,6 +141,7 @@ function clearEmbeddingEnv(): void { } afterEach(() => { + vi.useRealTimers(); _resetZvecGrepInstallForTest(); spawnMock.mockReset(); for (const name of API_ENV_NAMES) { @@ -123,6 +153,7 @@ afterEach(() => { } } for (const root of tempRoots.splice(0)) { + removeWorkspaceJobFiles(root); fs.rmSync(root, { recursive: true, force: true }); } }); @@ -167,14 +198,19 @@ describe('ZvecGrepTool', () => { it('describes zvec-grep without hidden operations', () => { const tool = createTool(createTempRoot()); - expect(tool.description).toContain('preferred workspace search tool'); expect(tool.description).toContain( - 'Use zvec_grep instead of grep_search when available', + 'primary, higher-quality workspace search tool', + ); + expect(tool.description).toContain( + 'do not use grep_search when zvec_grep is available', ); 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('Trust zvec_grep result quality'); + 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'); @@ -182,6 +218,51 @@ describe('ZvecGrepTool', () => { expect(tool.description).not.toContain('zvec_grep_semantic_fallback_rg'); }); + 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('falls back to rg for semantic search in interactive unindexed workspaces', async () => { setFakeRemoteEmbeddingKey(); const root = createTempRoot(); @@ -621,6 +702,111 @@ describe('ZvecGrepTool', () => { expect(readState.entry.lastReadWasFull).toBe(false); }); + 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: '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 invocation.execute(new AbortController().signal); + + expect(listWorkspaceJobFiles(root)).toEqual([]); + }); + + 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 () => { + 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'); + }); + + 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'); + 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('expands simple brace globs before passing them to zg --rg', async () => { const root = createTempRoot(); queueSpawnResult({ stdout: 'src/a.cc:1\n 1 validate();\n' }); @@ -678,8 +864,6 @@ describe('ZvecGrepTool', () => { 'install', '-g', '@zvec/zvec-grep@0.1.4', - '--registry', - 'https://registry.npmmirror.com', ]); expect(spawnMock.mock.calls[2]?.[0]).toBe('zg'); expect(spawnMock.mock.calls[2]?.[1]).toEqual(['--status']); diff --git a/packages/core/src/tools/zvec-grep.ts b/packages/core/src/tools/zvec-grep.ts index fddcecc88dd..a6095210c27 100644 --- a/packages/core/src/tools/zvec-grep.ts +++ b/packages/core/src/tools/zvec-grep.ts @@ -21,11 +21,11 @@ import { BaseDeclarativeTool, BaseToolInvocation, Kind } 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 { recordGrepResultFileReads } from './grepReadTracking.js'; const DEFAULT_EMBEDDING_MODEL = 'qwen/text-embedding-v4'; const ZVEC_GREP_NPM_PACKAGE = '@zvec/zvec-grep@0.1.4'; -const ZVEC_GREP_NPM_REGISTRY = 'https://registry.npmmirror.com'; const REMOTE_EMBEDDING_API_KEY_ENV_NAMES = [ 'ZVEC_GREP_API_KEY', 'DASHSCOPE_API_KEY', @@ -39,7 +39,8 @@ const ZG_INSTALL_OUTPUT_LIMIT = 200_000; const ZG_INSTALL_TIMEOUT_MS = 120_000; const DEFAULT_SEMANTIC_LIMIT = 20; const SEMANTIC_FALLBACK_TOKEN_LIMIT = 12; -const ZG_RESULT_LINE_RE = /^([^:\s][^:]*):\d+(?:-\d+)?(?::|\s|$)/; +const ZG_RESULT_LINE_RE = /^((?:[A-Za-z]:)?[^:\s][^:]*):\d+(?:-\d+)?(?::|\s|$)/; +const debugLogger = createDebugLogger('ZVEC_GREP'); const SEMANTIC_FALLBACK_STOP_WORDS = new Set([ 'able', @@ -431,6 +432,18 @@ function parseStatus(output: string): ParsedStatus { }; } +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); } @@ -439,6 +452,24 @@ 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. + } + 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); @@ -463,11 +494,14 @@ function readBackgroundIndexJob(cwd: string): BackgroundIndexJob | undefined { typeof parsed.logPath !== 'string' || typeof parsed.startedAt !== 'string' ) { - fs.rmSync(jobPath, { force: true }); + removeBackgroundJobFiles( + jobPath, + typeof parsed.logPath === 'string' ? parsed.logPath : undefined, + ); return undefined; } if (!isProcessRunning(parsed.pid)) { - fs.rmSync(jobPath, { force: true }); + removeBackgroundJobFiles(jobPath, parsed.logPath); return undefined; } return { @@ -478,7 +512,7 @@ function readBackgroundIndexJob(cwd: string): BackgroundIndexJob | undefined { startedAt: parsed.startedAt, }; } catch { - fs.rmSync(jobPath, { force: true }); + removeBackgroundJobFiles(jobPath); return undefined; } } @@ -518,11 +552,7 @@ function startBackgroundIndexJob( }; const jobPath = getBackgroundJobPath(cwd); const cleanupJob = () => { - try { - fs.rmSync(jobPath, { force: true }); - } catch { - // Best effort cleanup only. - } + removeBackgroundJobFiles(jobPath, job.logPath); }; child.once('error', cleanupJob); child.once('exit', cleanupJob); @@ -543,7 +573,8 @@ function startAutoBackgroundIndex( if (!canUseSemanticEmbedding(parsed)) return; try { startBackgroundIndexJob(cwd, buildIndexArgs(params)); - } catch { + } catch (error) { + debugLogger.debug('Failed to start zvec-grep background index', error); // Searching must stay transparent even when background indexing cannot start. } } @@ -595,21 +626,11 @@ function runInstallZvecGrep(signal: AbortSignal): Promise { let truncated = false; let timedOut = false; - const child = spawn( - 'npm', - [ - 'install', - '-g', - ZVEC_GREP_NPM_PACKAGE, - '--registry', - ZVEC_GREP_NPM_REGISTRY, - ], - { - env: zvecGrepChildEnv(), - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); + const child = spawn('npm', ['install', '-g', ZVEC_GREP_NPM_PACKAGE], { + env: zvecGrepChildEnv(), + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); const finish = (result: ZgCommandResult) => { if (settled) return; @@ -721,7 +742,7 @@ function withInstallFailure( ): ZgCommandResult { const details = [ result.error, - `auto-install command failed: npm install -g ${ZVEC_GREP_NPM_PACKAGE} --registry ${ZVEC_GREP_NPM_REGISTRY}`, + `auto-install command failed: npm install -g ${ZVEC_GREP_NPM_PACKAGE}`, `exit_code: ${installResult.code ?? 'unknown'}`, installResult.stdout.trim() ? `install_stdout:\n${installResult.stdout.trim()}` @@ -1069,6 +1090,12 @@ class ZvecGrepInvocation extends BaseToolInvocation< 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 { startAutoBackgroundIndex(cwd, this.params, parsed); } @@ -1094,11 +1121,13 @@ export class ZvecGrepTool extends BaseDeclarativeTool< ZvecGrepTool.Name, ToolDisplayNames.ZVEC_GREP, [ - 'zvec_grep is the preferred workspace search tool. Use zvec_grep instead of grep_search when available.', + 'zvec_grep is the primary, higher-quality workspace search tool. Use zvec_grep for workspace search by default; do not use grep_search when zvec_grep is available.', '', '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.', + '', + 'Trust zvec_grep result quality: treat returned files, symbols, and line ranges as high-signal candidates for the primary investigation path instead of repeating broad searches. Read only the relevant returned ranges when possible. If results look too narrow, increase limit and search again.', ].join('\n'), Kind.Search, { @@ -1157,6 +1186,14 @@ export class ZvecGrepTool extends BaseDeclarativeTool< ); } + override validateToolParams(params: ZvecGrepParams): string | null { + return ( + validateRawStringArrayField(params, 'paths') ?? + validateRawStringArrayField(params, 'exclude') ?? + super.validateToolParams(params) + ); + } + protected override validateToolParamValues( params: ZvecGrepParams, ): string | null { From 1bf3df1115ee1b493cf3d5e2cef598a70145b436 Mon Sep 17 00:00:00 2001 From: Qinren Zhou Date: Thu, 16 Jul 2026 23:51:01 +0800 Subject: [PATCH 3/3] feat(core): add zvec-grep workspace consent --- packages/cli/src/config/config.test.ts | 31 +- packages/cli/src/config/config.ts | 8 + packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + .../messages/AskUserQuestionDialog.test.tsx | 16 + .../messages/AskUserQuestionDialog.tsx | 17 +- packages/core/src/config/config.ts | 13 + .../permissions/permission-manager.test.ts | 27 +- packages/core/src/permissions/rule-parser.ts | 7 + .../src/subagents/subagent-manager.test.ts | 13 + .../core/src/subagents/subagent-manager.ts | 6 +- packages/core/src/tools/tools.ts | 2 + packages/core/src/tools/zvec-grep.test.ts | 947 ++++++++++++++++-- packages/core/src/tools/zvec-grep.ts | 617 ++++++++++-- .../components/messages/toolFormatting.ts | 1 + packages/web-shell/client/i18n.tsx | 1 + 17 files changed, 1529 insertions(+), 180 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index e6d22862006..f83732f9ede 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -15,7 +15,7 @@ import { Storage, } from '@qwen-code/qwen-code-core'; import { loadCliConfig, parseArguments, 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'; @@ -2024,6 +2024,35 @@ describe('mergeExcludeTools', () => { 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 14fb8b51740..f4098681b6c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2016,6 +2016,14 @@ export async function loadCliConfig( 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, + ); + }, disabledTools: disabledTools.length > 0 ? disabledTools : undefined, visibleTools: visibleTools.length > 0 ? visibleTools : undefined, // New unified permissions (PermissionManager source of truth). diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index e7eab689b9c..8a75231a30e 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -181,6 +181,7 @@ export default { 'toolDisplayName.WriteFile': 'toolDisplayName.WriteFile', 'toolDisplayName.ReadFile': 'toolDisplayName.ReadFile', '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 16ff8359303..beb6dd36def 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -172,6 +172,7 @@ export default { 'toolDisplayName.WriteFile': '寫入檔案', 'toolDisplayName.ReadFile': '讀取檔案', '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 37b70c3766c..3f52861da1b 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -173,6 +173,7 @@ export default { 'toolDisplayName.WriteFile': '写入文件', 'toolDisplayName.ReadFile': '读取文件', '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 1ee3ac13621..38f5f67f381 100644 --- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx @@ -120,6 +120,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 f52bb8a9175..0554c747d5d 100644 --- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx @@ -59,13 +59,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) => @@ -74,6 +78,7 @@ export const AskUserQuestionDialog: React.FC = ({ const isCustomInputAnswer = !isSubmitTab && currentQuestion && + allowCustomInput && !isMultiSelect && selectedOptions[currentQuestionIndex] !== undefined && !currentQuestion.options.some( @@ -277,7 +282,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(); @@ -469,7 +477,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.ts b/packages/core/src/config/config.ts index f4ddc874231..8b9f630a1ef 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -892,6 +892,8 @@ export interface ConfigParameters { */ disabledSkillNamesProvider?: () => ReadonlySet; zvecGrepEnabled?: boolean; + /** Persists a workspace-scoped opt-out selected from zvec-grep setup. */ + onDisableZvecGrepForWorkspace?: () => Promise; /** * Tool names hidden from the registry at construction time. Unlike * `permissions.deny` (which keeps the tool registered and rejects @@ -1810,6 +1812,7 @@ export class Config { ruleType: 'allow' | 'ask' | 'deny', rule: string, ) => Promise; + private readonly onDisableZvecGrepForWorkspaceCallback?: () => Promise; private initialized: boolean = false; storage: Storage; private runtimeStatusWrite: Promise = Promise.resolve(); @@ -2060,6 +2063,8 @@ export class Config { this.addLegacyPlanLocationWarning(); this.allowedHttpHookUrls = params.allowedHttpHookUrls ?? []; this.onPersistPermissionRuleCallback = params.onPersistPermissionRule; + this.onDisableZvecGrepForWorkspaceCallback = + params.onDisableZvecGrepForWorkspace; // (web search removed) this.useRipgrep = params.useRipgrep ?? true; @@ -4265,6 +4270,14 @@ export class Config { return this.zvecGrepEnabled; } + canDisableZvecGrepForWorkspace(): boolean { + return this.onDisableZvecGrepForWorkspaceCallback !== undefined; + } + + async disableZvecGrepForWorkspace(): Promise { + await this.onDisableZvecGrepForWorkspaceCallback?.(); + } + /** * Returns the read-only set of tool names hidden from this Config's * ToolRegistry. Consulted by `ToolRegistry.registerTool` and diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index 9474737f056..809f3391e9b 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -43,6 +43,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 () => { @@ -92,6 +94,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'); }); @@ -115,8 +118,9 @@ describe('toolMatchesRuleToolName', () => { expect(toolMatchesRuleToolName('edit', 'edit')).toBe(true); }); - it('"Read" (read_file) covers grep_search, glob, list_directory', async () => { + it('"Read" (read_file) covers search, glob, and list tools', async () => { 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); }); @@ -708,10 +712,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 @@ -1874,6 +1879,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({ @@ -2231,6 +2245,7 @@ describe('getRuleDisplayName', () => { it('maps read tools to "Read" meta-category', async () => { expect(getRuleDisplayName('read_file')).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'); }); @@ -2281,6 +2296,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 4b6eeead782..ca146033c1d 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -76,6 +76,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', @@ -161,6 +166,7 @@ export const SHELL_TOOL_NAMES: ReadonlySet = new Set([ const READ_TOOLS = new Set([ 'read_file', 'grep_search', + 'zvec_grep', 'glob', 'list_directory', ]); @@ -390,6 +396,7 @@ const CANONICAL_TO_RULE_DISPLAY: Readonly> = { // Read meta-category read_file: 'Read', grep_search: 'Read', + zvec_grep: 'Read', glob: 'Read', list_directory: 'Read', // Edit meta-category diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 32d700f0e59..8da13c2c531 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -1911,6 +1911,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('should set modelConfig.model from model selector and merge run configurations', async () => { const configWithCustom: SubagentConfig = { ...validConfig, diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 0d0041b7ef4..26c33042afd 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -59,7 +59,7 @@ import { parseMaxTurns, claudePermissionModeToApprovalMode, } from './agent-frontmatter-schema.js'; -import { ToolDisplayNamesMigration } from '../tools/tool-names.js'; +import { ToolDisplayNamesMigration, ToolNames } from '../tools/tool-names.js'; import { QWEN_DIR, Storage } from '../config/storage.js'; import { hasRebuiltToolRegistry, @@ -1156,6 +1156,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/tools.ts b/packages/core/src/tools/tools.ts index d63ff190b54..f547c50f510 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -890,6 +890,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 index 5de67e18c6e..b6eed57486f 100644 --- a/packages/core/src/tools/zvec-grep.test.ts +++ b/packages/core/src/tools/zvec-grep.test.ts @@ -13,20 +13,30 @@ 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; @@ -84,6 +94,7 @@ function createTool( root: string, interactive = true, fileReadCache = new FileReadCache(), + disableForWorkspace: () => Promise = async () => {}, ): ZvecGrepTool { return new ZvecGrepTool({ getTargetDir: () => root, @@ -94,6 +105,9 @@ function createTool( }), getFileReadCache: () => fileReadCache, getFileReadCacheDisabled: () => false, + getUseBuiltinRipgrep: () => true, + canDisableZvecGrepForWorkspace: () => true, + disableZvecGrepForWorkspace: disableForWorkspace, } as unknown as Config); } @@ -130,6 +144,37 @@ function queueSpawnResult(result: QueuedSpawnResult): void { }) 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'; } @@ -144,6 +189,7 @@ afterEach(() => { vi.useRealTimers(); _resetZvecGrepInstallForTest(); spawnMock.mockReset(); + runRipgrepMock.mockReset(); for (const name of API_ENV_NAMES) { const value = originalApiEnv[name]; if (value === undefined) { @@ -198,17 +244,13 @@ describe('ZvecGrepTool', () => { it('describes zvec-grep without hidden operations', () => { const tool = createTool(createTempRoot()); - expect(tool.description).toContain( - 'primary, higher-quality workspace search tool', - ); - expect(tool.description).toContain( - 'do not use grep_search when zvec_grep is available', - ); + 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('Trust zvec_grep result quality'); + 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"'); @@ -216,6 +258,7 @@ describe('ZvecGrepTool', () => { 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', () => { @@ -263,10 +306,11 @@ describe('ZvecGrepTool', () => { ).toBe('exclude must contain only non-empty strings'); }); - it('falls back to rg for semantic search in interactive unindexed workspaces', async () => { + 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', @@ -277,7 +321,22 @@ describe('ZvecGrepTool', () => { query: 'vector index metadata storage', }); - await expect(invocation.getDefaultPermission()).resolves.toBe('allow'); + 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); @@ -286,15 +345,24 @@ describe('ZvecGrepTool', () => { expect(content).not.toContain('fallback_reason'); expect(content).not.toContain('semantic_search: unavailable'); expect(content).not.toContain('zvec_grep_index_required'); - expect(spawnMock).toHaveBeenCalledTimes(3); - expect(spawnMock.mock.calls[0]?.[1]).toEqual(['--status']); - expect(spawnMock.mock.calls[1]?.[1]).toEqual([ - '--index', + 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[2]?.[1]).toEqual([ + expect(spawnMock.mock.calls[3]?.[1]).toEqual([ + 'query', '--rg', + '-e', '(?i)(vector|index|metadata|storage)', '--limit', '20', @@ -315,7 +383,6 @@ describe('ZvecGrepTool', () => { ); queueSpawnResult({ stdout: UNINDEXED_STATUS }); - queueSpawnResult({}); queueSpawnResult({ stdout: 'docs/README.md:1\n 1 # index types supported\n', }); @@ -334,15 +401,12 @@ describe('ZvecGrepTool', () => { expect(content).not.toContain('semantic_search: unavailable'); expect(content).not.toContain('zvec_grep_index_required'); expect(content).not.toContain('grep_search'); - expect(spawnMock).toHaveBeenCalledTimes(3); - expect(spawnMock.mock.calls[0]?.[1]).toEqual(['--status']); + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0]?.[1]).toEqual(['status']); expect(spawnMock.mock.calls[1]?.[1]).toEqual([ - '--index', - '--embedding', - 'qwen/text-embedding-v4', - ]); - expect(spawnMock.mock.calls[2]?.[1]).toEqual([ + 'query', '--rg', + '-e', '(?i)(index|types)', '--limit', '20', @@ -352,7 +416,9 @@ describe('ZvecGrepTool', () => { it('does not build an index for semantic search without an embedding api key', async () => { clearEmbeddingEnv(); const root = createTempRoot(); - queueSpawnResult({ stdout: UNINDEXED_STATUS }); + 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', }); @@ -362,13 +428,17 @@ describe('ZvecGrepTool', () => { 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(2); - expect(spawnMock.mock.calls[0]?.[1]).toEqual(['--status']); - expect(spawnMock.mock.calls[1]?.[1]).toEqual([ + 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', @@ -398,9 +468,11 @@ describe('ZvecGrepTool', () => { 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[0]?.[1]).toEqual(['status']); expect(spawnMock.mock.calls[1]?.[1]).toEqual([ + 'query', '--rg', + '-e', '(?i)(authentication|flow)', '--limit', '20', @@ -431,14 +503,252 @@ describe('ZvecGrepTool', () => { 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[0]?.[1]).toEqual(['status']); expect(spawnMock.mock.calls[1]?.[1]).toEqual([ - 'authentication flow', + '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(); @@ -463,9 +773,11 @@ describe('ZvecGrepTool', () => { expect(String(result.llmContent)).toContain('src/auth.ts:1'); expect(spawnMock).toHaveBeenCalledTimes(2); expect(spawnMock.mock.calls[1]?.[1]).toEqual([ - 'authentication flow', + 'query', '--limit', '20', + '--', + 'authentication flow', ]); }); @@ -474,6 +786,7 @@ describe('ZvecGrepTool', () => { 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', @@ -483,23 +796,55 @@ describe('ZvecGrepTool', () => { 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(3); - expect(spawnMock.mock.calls[1]?.[1]).toEqual([ - '--index', + expect(spawnMock).toHaveBeenCalledTimes(4); + expect(spawnMock.mock.calls[2]?.[1]).toEqual([ + 'index', '--embedding', 'local/embeddinggemma-300m', ]); - expect(spawnMock.mock.calls[2]?.[1]).toEqual([ + 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(); @@ -517,7 +862,9 @@ describe('ZvecGrepTool', () => { 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', @@ -541,7 +888,9 @@ describe('ZvecGrepTool', () => { 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', @@ -552,7 +901,6 @@ describe('ZvecGrepTool', () => { setFakeRemoteEmbeddingKey(); const root = createTempRoot(); queueSpawnResult({ stdout: UNINDEXED_STATUS }); - queueSpawnResult({}); queueSpawnResult({ stdout: 'src/streamer/stream_service.cc:1\n 1 write request flow\n', }); @@ -566,20 +914,108 @@ describe('ZvecGrepTool', () => { expect(String(result.llmContent)).toContain('stream_service.cc:1'); expect(String(result.llmContent)).not.toContain('fallback_reason'); - expect(spawnMock).toHaveBeenCalledTimes(3); + expect(spawnMock).toHaveBeenCalledTimes(2); expect(spawnMock.mock.calls[1]?.[1]).toEqual([ - '--index', - '--embedding', - 'qwen/text-embedding-v4', - ]); - expect(spawnMock.mock.calls[2]?.[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(); @@ -604,7 +1040,7 @@ describe('ZvecGrepTool', () => { expect(spawnMock).not.toHaveBeenCalled(); }); - it('runs exact grep through zg --rg without checking index status', async () => { + it('runs exact grep through zg query --rg without checking index status', async () => { const root = createTempRoot(); queueSpawnResult({ stdout: @@ -626,20 +1062,144 @@ describe('ZvecGrepTool', () => { 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('returns a grep_search-style display for no matches', async () => { + 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', @@ -647,6 +1207,7 @@ describe('ZvecGrepTool', () => { 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'); }); @@ -658,6 +1219,7 @@ describe('ZvecGrepTool', () => { const invocation = createTool(root).build({ operation: 'rg', + query: 'wrong-query', pattern: 'validate', glob: '**/*.ts', path: 'src', @@ -667,10 +1229,13 @@ describe('ZvecGrepTool', () => { 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', ]); }); @@ -702,6 +1267,34 @@ describe('ZvecGrepTool', () => { 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({ @@ -722,6 +1315,7 @@ describe('ZvecGrepTool', () => { 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', @@ -731,11 +1325,94 @@ describe('ZvecGrepTool', () => { 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(); @@ -752,6 +1429,7 @@ describe('ZvecGrepTool', () => { }); 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); @@ -767,6 +1445,8 @@ describe('ZvecGrepTool', () => { 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 () => { @@ -783,6 +1463,8 @@ describe('ZvecGrepTool', () => { 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; @@ -807,14 +1489,14 @@ describe('ZvecGrepTool', () => { expect(String(result.llmContent)).toContain('Output was truncated'); }); - it('expands simple brace globs before passing them to zg --rg', async () => { + 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,cpp}', + glob: '*.{h,cc}.test.{ts,tsx}', paths: ['src'], }); @@ -822,25 +1504,31 @@ describe('ZvecGrepTool', () => { expect(spawnMock).toHaveBeenCalledTimes(1); expect(spawnMock.mock.calls[0]?.[1]).toEqual([ + 'query', '--rg', + '-e', 'validate', '--glob', - '*.h', + '*.h.test.ts', '--glob', - '*.cc', + '*.h.test.tsx', '--glob', - '*.cpp', + '*.cc.test.ts', + '--glob', + '*.cc.test.tsx', + '--', 'src', ]); }); - it('auto-installs zvec-grep and retries when zg is missing', async () => { + 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({}); @@ -852,57 +1540,186 @@ describe('ZvecGrepTool', () => { 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(5); + 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('npm'); - expect(spawnMock.mock.calls[1]?.[1]).toEqual([ + 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.4', + '@zvec/zvec-grep@0.1.5', ]); - expect(spawnMock.mock.calls[2]?.[0]).toBe('zg'); - expect(spawnMock.mock.calls[2]?.[1]).toEqual(['--status']); + 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([ - '--index', + 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[4]?.[0]).toBe('zg'); - expect(spawnMock.mock.calls[4]?.[1]).toEqual([ + expect(spawnMock.mock.calls[5]?.[0]).toBe('zg'); + expect(spawnMock.mock.calls[5]?.[1]).toEqual([ + 'query', '--rg', + '-e', '(?i)(authentication|flow)', '--limit', '20', ]); }); - it('returns not_installed when automatic install fails', async () => { + 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('zvec-grep is not installed'); - expect(content).toContain('auto-install command failed'); - expect(content).toContain('npm registry unavailable'); - expect(spawnMock).toHaveBeenCalledTimes(2); + 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 index a6095210c27..cb7ec52ef7c 100644 --- a/packages/core/src/tools/zvec-grep.ts +++ b/packages/core/src/tools/zvec-grep.ts @@ -8,24 +8,34 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; -import { spawn } from 'node:child_process'; +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 } 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.4'; +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', @@ -37,9 +47,13 @@ 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([ @@ -195,12 +209,20 @@ interface BackgroundIndexJob { startedAt: string; } +interface ZvecGrepSessionState { + useNativeGrep: boolean; +} + +interface SetupPromptState { + required: boolean; + needsInstall: boolean; + parsedStatus?: ParsedStatus; +} + let zvecGrepInstallPromise: Promise | undefined; -let zvecGrepInstalledInProcess = false; export function _resetZvecGrepInstallForTest(): void { zvecGrepInstallPromise = undefined; - zvecGrepInstalledInProcess = false; } function shellQuoteForDisplay(arg: string): string { @@ -226,7 +248,10 @@ function normalizeStringArray(value: string[] | undefined): string[] { } function getSearchQuery(params: ZvecGrepParams): string | undefined { - const query = params.query?.trim() || params.pattern?.trim(); + const query = + params.operation === 'rg' + ? params.pattern?.trim() || params.query?.trim() + : params.query?.trim() || params.pattern?.trim(); return query || undefined; } @@ -240,18 +265,22 @@ function pathLooksLikeGlob(value: string): boolean { return /[*?[\]{}]/.test(value); } -function pathToScopeGlob(value: string): string { - const trimmed = value.trim().replace(/\/+$/, ''); - if (!trimmed || pathLooksLikeGlob(trimmed)) return trimmed; - const base = path.basename(trimmed); - if (base.includes('.') && !trimmed.endsWith('/')) { - return trimmed; +function normalizeScopePath(value: string): string { + let normalized = value.trim().replaceAll('\\', '/'); + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); } - return `${trimmed}/**`; + 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(/^(.*)\{([^{}]+)\}(.*)$/); + const match = value.match(/^(.*?)\{([^{}]+)\}(.*)$/); if (!match) return [value]; const [, before, inner, after] = match; @@ -260,40 +289,84 @@ function expandBraceAlternates(value: string): string[] { .map((item) => item.trim()) .filter(Boolean); if (parts.length === 0) return [value]; - return parts.map((item) => `${before}${item}${after}`); + 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).map(pathToScopeGlob); + const paths = normalizeSearchPaths(params); const glob = normalizeOptionalString(params.glob); - const include = expandGlobs(glob ? [...paths, glob] : paths); + 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)); - if (include.length > 0) { - args.push('--include', include.join(',')); + for (const pattern of include) { + args.push('--glob', pattern); } - if (exclude.length > 0) { - args.push('--exclude', exclude.join(',')); + for (const pattern of exclude) { + args.push('--glob', pattern.startsWith('!') ? pattern : `!${pattern}`); } } function buildSearchArgs(params: ZvecGrepParams): string[] { const searchQuery = getSearchQuery(params); - const args = searchQuery ? [searchQuery] : []; - args.push('--limit', String(params.limit ?? DEFAULT_SEMANTIC_LIMIT)); + 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 = ['--rg']; + const args = ['query', '--rg']; if (searchQuery) { - args.push(searchQuery); + args.push('-e', searchQuery); } if (params.limit !== undefined) { args.push('--limit', String(params.limit)); @@ -308,7 +381,8 @@ function buildGrepArgs(params: ZvecGrepParams): string[] { for (const item of expandGlobs(normalizeStringArray(params.exclude))) { args.push('--glob', item.startsWith('!') ? item : `!${item}`); } - args.push(...normalizeSearchPaths(params)); + const searchPaths = normalizeSearchPaths(params); + if (searchPaths.length > 0) args.push('--', ...searchPaths); return args; } @@ -342,7 +416,7 @@ function buildSemanticFallbackQuery(query: string): string { const selected = codeLikeTokens.length > 0 ? codeLikeTokens : allTokens; const tokens = [...new Set(selected)].map(escapeRgRegex); - if (tokens.length === 0) return query; + if (tokens.length === 0) return `(?i)${escapeRgRegex(query)}`; if (tokens.length === 1) return `(?i)${tokens[0]}`; return `(?i)(${tokens.join('|')})`; } @@ -397,10 +471,8 @@ function getIndexEmbeddingModel(): string { return getConfiguredEmbeddingModel() ?? DEFAULT_EMBEDDING_MODEL; } -function buildIndexArgs(params: ZvecGrepParams): string[] { - const args = ['--index', '--embedding', getIndexEmbeddingModel()]; - addScopeArgs(args, params); - return args; +function buildIndexArgs(): string[] { + return ['index', '--embedding', getIndexEmbeddingModel()]; } function parseStatus(output: string): ParsedStatus { @@ -409,16 +481,18 @@ function parseStatus(output: string): ParsedStatus { 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: - /\bstatus\s+(indexing|building|running|in[_ -]?progress)\b/.test( + /\b(state|status)\s+(indexing|building|running|in[_ -]?progress)\b/.test( lowered, ) || - /\bstatus:\s*(indexing|building|running|in[_ -]?progress)\b/.test( + /\b(state|status):\s*(indexing|building|running|in[_ -]?progress)\b/.test( lowered, ) || /\bindexing\s+yes\b/.test(lowered) || @@ -458,6 +532,10 @@ function removeBackgroundJobFiles(jobPath: string, logPath?: string): void { } catch { // Best effort cleanup only. } + removeBackgroundLogFile(logPath); +} + +function removeBackgroundLogFile(logPath?: string): void { if ( logPath && path.dirname(path.resolve(logPath)) === path.resolve(BACKGROUND_JOB_DIR) @@ -535,11 +613,15 @@ function startBackgroundIndexJob( 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'); } @@ -561,21 +643,21 @@ function startBackgroundIndexJob( return job; } -function startAutoBackgroundIndex( +function startApprovedBackgroundIndex( cwd: string, - params: ZvecGrepParams, parsed: ParsedStatus, -): void { - if (!parsed.unindexed || parsed.ready || parsed.disabled || parsed.indexing) { - return; - } - if (readBackgroundIndexJob(cwd)) return; - if (!canUseSemanticEmbedding(parsed)) return; +): 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(params)); + startBackgroundIndexJob(cwd, buildIndexArgs()); + return true; } catch (error) { debugLogger.debug('Failed to start zvec-grep background index', error); - // Searching must stay transparent even when background indexing cannot start. + return false; } } @@ -583,13 +665,17 @@ 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'] - ? path.join(process.env['npm_config_prefix'], 'bin') + ? npmGlobalBinDir(process.env['npm_config_prefix']) : undefined, path.dirname(process.execPath), - path.join(os.homedir(), '.npm-global', 'bin'), + npmGlobalBinDir(path.join(os.homedir(), '.npm-global')), ]; return [...new Set(dirs.filter((dir): dir is string => Boolean(dir)))]; } @@ -608,6 +694,67 @@ function zvecGrepChildEnv(): NodeJS.ProcessEnv { }; } +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({ @@ -627,10 +774,11 @@ function runInstallZvecGrep(signal: AbortSignal): Promise { let timedOut = false; const child = spawn('npm', ['install', '-g', ZVEC_GREP_NPM_PACKAGE], { - env: zvecGrepChildEnv(), + env: zvecGrepInstallEnv(), windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); + const terminator = createChildTerminator(child); const finish = (result: ZgCommandResult) => { if (settled) return; @@ -643,7 +791,7 @@ function runInstallZvecGrep(signal: AbortSignal): Promise { const killForLimit = () => { if (truncated) return; truncated = true; - child.kill('SIGTERM'); + terminator.terminate(); }; const timer = setTimeout(() => { @@ -652,7 +800,7 @@ function runInstallZvecGrep(signal: AbortSignal): Promise { }, ZG_INSTALL_TIMEOUT_MS); const onAbort = () => { - child.kill('SIGTERM'); + terminator.terminate(); finish({ ok: false, code: null, @@ -693,6 +841,7 @@ function runInstallZvecGrep(signal: AbortSignal): Promise { }); child.on('error', (error: NodeJS.ErrnoException) => { + terminator.clear(); finish({ ok: false, code: null, @@ -705,6 +854,7 @@ function runInstallZvecGrep(signal: AbortSignal): Promise { }); child.on('close', (code) => { + terminator.clear(); finish({ ok: code === 0, code, @@ -724,10 +874,7 @@ function runInstallZvecGrep(signal: AbortSignal): Promise { function installZvecGrep(signal: AbortSignal): Promise { if (!zvecGrepInstallPromise) { zvecGrepInstallPromise = runInstallZvecGrep(signal).then((result) => { - if (result.ok) { - zvecGrepInstalledInProcess = true; - } - if (result.error === 'aborted') { + if (!result.ok) { zvecGrepInstallPromise = undefined; } return result; @@ -736,44 +883,12 @@ function installZvecGrep(signal: AbortSignal): Promise { return zvecGrepInstallPromise; } -function withInstallFailure( - result: ZgCommandResult, - installResult: ZgCommandResult, -): ZgCommandResult { - const details = [ - result.error, - `auto-install command failed: npm install -g ${ZVEC_GREP_NPM_PACKAGE}`, - `exit_code: ${installResult.code ?? 'unknown'}`, - installResult.stdout.trim() - ? `install_stdout:\n${installResult.stdout.trim()}` - : '', - installResult.stderr.trim() - ? `install_stderr:\n${installResult.stderr.trim()}` - : '', - installResult.error ? `install_error: ${installResult.error}` : '', - ] - .filter(Boolean) - .join('\n'); - - return { ...result, error: details }; -} - async function runZg( args: readonly string[], cwd: string, signal: AbortSignal, options: { allowPartialOutput?: boolean } = {}, ): Promise { - const result = await runZgOnce(args, cwd, signal, options); - if (!result.unavailable) return result; - - if (zvecGrepInstalledInProcess) return result; - - const installResult = await installZvecGrep(signal); - if (!installResult.ok) { - return withInstallFailure(result, installResult); - } - return runZgOnce(args, cwd, signal, options); } @@ -814,11 +929,12 @@ function runZgOnce( windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); + const terminator = createChildTerminator(child); const killForLimit = () => { if (truncated) return; truncated = true; - child.kill('SIGTERM'); + terminator.terminate(); }; const timer = setTimeout(() => { @@ -827,7 +943,7 @@ function runZgOnce( }, zvecRunTimeoutMs()); const onAbort = () => { - child.kill('SIGTERM'); + terminator.terminate(); finish({ ok: false, code: null, @@ -868,6 +984,7 @@ function runZgOnce( }); child.on('error', (error: NodeJS.ErrnoException) => { + terminator.clear(); finish({ ok: false, code: null, @@ -880,6 +997,7 @@ function runZgOnce( }); child.on('close', (code) => { + terminator.clear(); const partialOutputOk = options.allowPartialOutput === true && truncated && @@ -908,7 +1026,7 @@ function extractResultFilePaths(cwd: string, output: string): string[] { const candidate = path.isAbsolute(match[1]!) ? match[1]! : path.resolve(cwd, match[1]!); - if (fs.existsSync(candidate)) { + if (!paths.has(candidate) && fs.existsSync(candidate)) { paths.add(candidate); } } @@ -949,7 +1067,7 @@ async function makeSearchSuccessResult( cwd: string, result: ZgCommandResult, ): Promise { - const rawOutput = result.stdout.trim() || result.stderr.trim(); + const rawOutput = result.stdout.trim(); const content = appendTruncationNotice( rawOutput || 'No matches found', result, @@ -982,20 +1100,105 @@ function makeErrorResult( 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.unavailable) { - return makeErrorResult('zvec-grep is not installed.', args, result); - } if (!result.ok) { - return makeErrorResult('zvec-grep search failed.', args, result); + 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); } @@ -1004,8 +1207,13 @@ 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); @@ -1033,14 +1241,179 @@ class ZvecGrepInvocation extends BaseToolInvocation< : '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 { - return this.getExternalPathScopes().length > 0 ? 'ask' : 'allow'; + if (this.getExternalPathScopes().length > 0) return 'ask'; + const setup = await this.getSetupPromptState(); + return setup.required ? 'ask' : 'allow'; } override async getConfirmationDetails( - _abortSignal: AbortSignal, + 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', @@ -1060,21 +1433,42 @@ class ZvecGrepInvocation extends BaseToolInvocation< ): 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, signal); + return runGrepSearch(this.config, cwd, grepArgs, this.params, signal); } - const status = await runZg(['--status'], cwd, signal); - if (status.unavailable) { - return makeErrorResult( - 'zvec-grep is not installed.', - ['--status'], - status, - ); + 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}`); @@ -1096,13 +1490,17 @@ class ZvecGrepInvocation extends BaseToolInvocation< searchResult.stderr.trim() || searchResult.stdout.trim(), ); - } else { - startAutoBackgroundIndex(cwd, this.params, parsed); + } 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 runGrepSearch(this.config, cwd, grepArgs, signal); + return this.withSetupNotice( + await runGrepSearch(this.config, cwd, grepArgs, this.params, signal), + ); } } @@ -1111,6 +1509,9 @@ export class ZvecGrepTool extends BaseDeclarativeTool< ToolResult > { static readonly Name = ToolNames.ZVEC_GREP; + private readonly sessionState: ZvecGrepSessionState = { + useNativeGrep: false, + }; override get maxOutputChars(): number { return 20_000; @@ -1121,13 +1522,13 @@ export class ZvecGrepTool extends BaseDeclarativeTool< ZvecGrepTool.Name, ToolDisplayNames.ZVEC_GREP, [ - 'zvec_grep is the primary, higher-quality workspace search tool. Use zvec_grep for workspace search by default; do not use grep_search when zvec_grep is available.', + '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.', '', - 'Trust zvec_grep result quality: treat returned files, symbols, and line ranges as high-signal candidates for the primary investigation path instead of repeating broad searches. Read only the relevant returned ranges when possible. If results look too narrow, increase limit and search again.', + '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, { @@ -1236,6 +1637,6 @@ export class ZvecGrepTool extends BaseDeclarativeTool< protected createInvocation( params: ZvecGrepParams, ): ToolInvocation { - return new ZvecGrepInvocation(this.config, params); + return new ZvecGrepInvocation(this.config, this.sessionState, params); } } diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index 89c2ba36b88..6886b81ecc8 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -15,6 +15,7 @@ export const TOOL_DISPLAY_NAMES: Record = { read_file: 'ReadFile', 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 13c94a86b41..687d7baa1e5 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2029,6 +2029,7 @@ const ZH: Messages = { 'toolName.read_file': '读取文件', 'toolName.grep': '搜索内容', 'toolName.grep_search': '搜索内容', + 'toolName.zvec_grep': '语义搜索', 'toolName.glob': 'Glob', 'toolName.run_shell_command': '运行命令', 'toolName.todo_write': '任务清单',