Compare commits

...

13 Commits

Author SHA1 Message Date
pomelo-nwu
5f08128d07 docs: Update security policy with Alibaba contact information 2025-08-20 15:21:56 +08:00
pomelo
93f5e59710 Merge pull request #171 from dowithless/patch-1
doc: Add links to translated README versions
2025-08-19 15:13:42 +08:00
Fan
7b378e826c feat: project/global save location option (#368) 2025-08-18 23:09:50 +08:00
thuan1412
5e70b34041 feat: use .geminiignore in grep tool (#349)
* feat: use .geminiignore in grep tool
2025-08-18 11:37:26 +08:00
tanzhenxin
df1479f864 Chore/release 0.0.7 (#343)
* chore: pump version to 0.0.7 and add changelog.md
2025-08-15 18:49:13 +08:00
Mingholy
14e6d3c01e Update qwen-code-pr-review.yml
Trigger Qwen PR Review when a PR opens.
Fix the auto-skip issue.
2025-08-15 18:24:43 +08:00
pomelo
da0b8b5534 Merge pull request #340 from QwenLM/feat/web_fetch_tool
feat: refactor web-fetch tool to remove google genai dependency
2025-08-15 18:10:32 +08:00
tanzhenxin
e1d502991d chore: remove https restricton 2025-08-15 17:58:05 +08:00
tanzhenxin
7e01554b9c chore: fix test case failure 2025-08-15 17:27:09 +08:00
tanzhenxin
36c65658ff chore: npm run lint 2025-08-15 17:16:05 +08:00
tanzhenxin
a925ac56fa Potential fix for code scanning alert no. 24: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-15 17:10:20 +08:00
tanzhenxin
5d4a9452d8 feat: refactor web-fetch tool to remove google genai dependency 2025-08-15 17:06:00 +08:00
neo
a5a3da01f6 doc: Add links to translated README versions
Added language selection links to the README for easier access to translated versions: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
2025-08-01 15:18:26 +08:00
18 changed files with 332 additions and 290 deletions

View File

@@ -24,7 +24,7 @@ jobs:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
with:
version: 0.0.6
version: 0.0.7
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
settings_json: |
{

View File

@@ -42,7 +42,7 @@ jobs:
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
REPOSITORY: ${{ github.repository }}
with:
version: 0.0.6
version: 0.0.7
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}

View File

@@ -18,7 +18,11 @@ jobs:
review-pr:
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.action == 'opened') ||
(github.event_name == 'pull_request_target' &&
github.event.action == 'opened' &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')) ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@qwen /review') &&
@@ -49,9 +53,9 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Get PR details (pull_request & workflow_dispatch)
- name: Get PR details (pull_request_target & workflow_dispatch)
id: get_pr
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |

26
CHANGELOG.md Normal file
View File

@@ -0,0 +1,26 @@
# Changelog
## 0.0.7
- Fix MCP tools
- Fix Web Fetch tool
- Fix Web Search tool, by replacing web search from Google/Gemini to Tavily API
- Fix: Compatible with occasional tool call parameters returned by LLM that are invalid JSON
- Fix: prevent concurrent query submissions on some rare cases
- Fix: incorrect qwen logger exit handler setup
- Fix: seperate static QR code and dynamic spin components
- Sync gemini-cli to v0.1.18
## 0.0.6
- Add usage statistics logging for Qwen integration
- Make `/init` command respect configured context filename and align docs with QWEN.md
- Fix EPERM error when run `qwen --sandbox` in macOS
- Fix terminal flicker when waiting for login
- Fix `glm-4.5` model request error
## 0.0.5
- Support Qwen OAuth login and provide up to 2000 free requests per day
- Sync gemini-cli to v0.1.17
- Add systemPromptMappings Configuration Feature

View File

@@ -15,6 +15,20 @@
</div>
<div align="center">
<!-- Keep these links. Translations will automatically update with the README. -->
<a href="https://readme-i18n.com/de/QwenLM/qwen-code">Deutsch</a> |
<a href="https://readme-i18n.com/es/QwenLM/qwen-code">Español</a> |
<a href="https://readme-i18n.com/fr/QwenLM/qwen-code">français</a> |
<a href="https://readme-i18n.com/ja/QwenLM/qwen-code">日本語</a> |
<a href="https://readme-i18n.com/ko/QwenLM/qwen-code">한국어</a> |
<a href="https://readme-i18n.com/pt/QwenLM/qwen-code">Português</a> |
<a href="https://readme-i18n.com/ru/QwenLM/qwen-code">Русский</a> |
<a href="https://readme-i18n.com/zh/QwenLM/qwen-code">中文</a>
</div>
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
## 💡 Free Options Available

View File

@@ -1,8 +1,5 @@
# Reporting Security Issues
To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz).
We use g.co/vulnz for our intake, and do coordination and disclosure here on
GitHub (including using GitHub Security Advisory). The Google Security Team will
respond within 5 working days of your report on g.co/vulnz.
Please report any security issue or Higress crash report to [ASRC](https://security.alibaba.com/) (Alibaba Security Response Center) where the issue will be triaged appropriately.
[GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories
Thank you for helping keep our project secure.

View File

@@ -4,24 +4,25 @@ This document describes the `web_fetch` tool for the Gemini CLI.
## Description
Use `web_fetch` to summarize, compare, or extract information from web pages. The `web_fetch` tool processes content from one or more URLs (up to 20) embedded in a prompt. `web_fetch` takes a natural language prompt and returns a generated response.
Use `web_fetch` to fetch content from a specified URL and process it using an AI model. The tool takes a URL and a prompt as input, fetches the URL content, converts HTML to markdown, and processes the content with the prompt using a small, fast model.
### Arguments
`web_fetch` takes one argument:
`web_fetch` takes two arguments:
- `prompt` (string, required): A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content. For example: `"Summarize https://example.com/article and extract key points from https://another.com/data"`. The prompt must contain at least one URL starting with `http://` or `https://`.
- `url` (string, required): The URL to fetch content from. Must be a fully-formed valid URL starting with `http://` or `https://`.
- `prompt` (string, required): The prompt describing what information you want to extract from the page content.
## How to use `web_fetch` with the Gemini CLI
To use `web_fetch` with the Gemini CLI, provide a natural language prompt that contains URLs. The tool will ask for confirmation before fetching any URLs. Once confirmed, the tool will process URLs through Gemini API's `urlContext`.
To use `web_fetch` with the Gemini CLI, provide a URL and a prompt describing what you want to extract from that URL. The tool will ask for confirmation before fetching the URL. Once confirmed, the tool will fetch the content directly and process it using an AI model.
If the Gemini API cannot access the URL, the tool will fall back to fetching content directly from the local machine. The tool will format the response, including source attribution and citations where possible. The tool will then provide the response to the user.
The tool automatically converts HTML to text, handles GitHub blob URLs (converting them to raw URLs), and upgrades HTTP URLs to HTTPS for security.
Usage:
```
web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
web_fetch(url="https://example.com", prompt="Summarize the main points of this article")
```
## `web_fetch` examples
@@ -29,16 +30,25 @@ web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
Summarize a single article:
```
web_fetch(prompt="Can you summarize the main points of https://example.com/news/latest")
web_fetch(url="https://example.com/news/latest", prompt="Can you summarize the main points of this article?")
```
Compare two articles:
Extract specific information:
```
web_fetch(prompt="What are the differences in the conclusions of these two papers: https://arxiv.org/abs/2401.0001 and https://arxiv.org/abs/2401.0002?")
web_fetch(url="https://arxiv.org/abs/2401.0001", prompt="What are the key findings and methodology described in this paper?")
```
Analyze GitHub documentation:
```
web_fetch(url="https://github.com/google/gemini-react/blob/main/README.md", prompt="What are the installation steps and main features?")
```
## Important notes
- **URL processing:** `web_fetch` relies on the Gemini API's ability to access and process the given URLs.
- **Single URL processing:** `web_fetch` processes one URL at a time. To analyze multiple URLs, make separate calls to the tool.
- **URL format:** The tool automatically upgrades HTTP URLs to HTTPS and converts GitHub blob URLs to raw format for better content access.
- **Content processing:** The tool fetches content directly and processes it using an AI model, converting HTML to readable text format.
- **Output quality:** The quality of the output will depend on the clarity of the instructions in the prompt.
- **MCP tools:** If an MCP-provided web fetch tool is available (starting with "mcp\_\_"), prefer using that tool as it may have fewer restrictions.

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.6",
"version": "0.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.0.6",
"version": "0.0.7",
"workspaces": [
"packages/*"
],
@@ -11700,7 +11700,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.0.6",
"version": "0.0.7",
"dependencies": {
"@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5",
@@ -11904,7 +11904,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.0.6",
"version": "0.0.7",
"dependencies": {
"@google/genai": "1.9.0",
"@modelcontextprotocol/sdk": "^1.11.0",
@@ -12063,7 +12063,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.0.6",
"version": "0.0.7",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.6",
"version": "0.0.7",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7"
},
"scripts": {
"start": "node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.6",
"version": "0.0.7",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7"
},
"dependencies": {
"@google/genai": "1.9.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.0.6",
"version": "0.0.7",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -11,6 +11,7 @@ import fs from 'fs/promises';
import os from 'os';
import { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
// Mock the child_process module to control grep/git grep behavior
vi.mock('child_process', () => ({
@@ -32,9 +33,14 @@ describe('GrepTool', () => {
let grepTool: GrepTool;
const abortSignal = new AbortController().signal;
const mockFileService = {
getGeminiIgnorePatterns: () => [],
} as unknown as FileDiscoveryService;
const mockConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
getFileService: () => mockFileService,
} as unknown as Config;
beforeEach(async () => {
@@ -220,6 +226,43 @@ describe('GrepTool', () => {
"Model provided invalid parameters. Error: params must have required property 'pattern'",
);
});
it('should exclude files matching geminiIgnorePatterns', async () => {
// Create a file that should be ignored
await fs.writeFile(
path.join(tempRootDir, 'ignored-file.txt'),
'this file should be ignored\nit contains the word world',
);
// Update the mock file service to return ignore patterns
mockFileService.getGeminiIgnorePatterns = () => ['ignored-file.txt'];
// Re-create the grep tool with the updated mock
const grepToolWithIgnore = new GrepTool(mockConfig);
// Search for 'world' which exists in both the regular file and the ignored file
const params: GrepToolParams = { pattern: 'world' };
const result = await grepToolWithIgnore.execute(params, abortSignal);
// Should only find matches in the non-ignored files (3 matches)
expect(result.llmContent).toContain(
'Found 3 matches for pattern "world" in the workspace directory',
);
// Should find matches in the regular files
expect(result.llmContent).toContain('File: fileA.txt');
expect(result.llmContent).toContain('L1: hello world');
expect(result.llmContent).toContain('L2: second line with world');
expect(result.llmContent).toContain(
`File: ${path.join('sub', 'fileC.txt')}`,
);
expect(result.llmContent).toContain('L1: another world in sub dir');
// Should NOT find matches in the ignored file
expect(result.llmContent).not.toContain('ignored-file.txt');
expect(result.returnDisplay).toBe('Found 3 matches');
});
});
describe('multi-directory workspace', () => {
@@ -238,10 +281,15 @@ describe('GrepTool', () => {
);
// Create a mock config with multiple directories
const multiDirFileService = {
getGeminiIgnorePatterns: () => [],
};
const multiDirConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
getFileService: () => multiDirFileService,
} as unknown as Config;
const multiDirGrepTool = new GrepTool(multiDirConfig);
@@ -287,10 +335,15 @@ describe('GrepTool', () => {
);
// Create a mock config with multiple directories
const multiDirFileService = {
getGeminiIgnorePatterns: () => [],
};
const multiDirConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
getFileService: () => multiDirFileService,
} as unknown as Config;
const multiDirGrepTool = new GrepTool(multiDirConfig);

View File

@@ -536,12 +536,18 @@ export class GrepTool extends BaseTool<GrepToolParams, ToolResult> {
);
strategyUsed = 'javascript fallback';
const globPattern = include ? include : '**/*';
// Get the file discovery service to check ignore patterns
const fileDiscovery = this.config.getFileService();
// Basic ignore patterns
const ignorePatterns = [
'.git/**',
'node_modules/**',
'bower_components/**',
'.svn/**',
'.hg/**',
...fileDiscovery.getGeminiIgnorePatterns(),
]; // Use glob patterns for ignores here
const filesIterator = globIterate(globPattern, {

View File

@@ -203,7 +203,7 @@ describe('MemoryTool', () => {
});
it('should call performAddMemoryEntry with correct parameters and return success', async () => {
const params = { fact: 'The sky is blue' };
const params = { fact: 'The sky is blue', scope: 'global' as const };
const result = await memoryTool.execute(params, mockAbortSignal);
// Use getCurrentGeminiMdFilename for the default expectation before any setGeminiMdFilename calls in a test
const expectedFilePath = path.join(
@@ -224,7 +224,7 @@ describe('MemoryTool', () => {
expectedFilePath,
expectedFsArgument,
);
const successMessage = `Okay, I've remembered that: "${params.fact}"`;
const successMessage = `Okay, I've remembered that in global memory: "${params.fact}"`;
expect(result.llmContent).toBe(
JSON.stringify({ success: true, message: successMessage }),
);
@@ -244,7 +244,7 @@ describe('MemoryTool', () => {
});
it('should handle errors from performAddMemoryEntry', async () => {
const params = { fact: 'This will fail' };
const params = { fact: 'This will fail', scope: 'global' as const };
const underlyingError = new Error(
'[MemoryTool] Failed to add memory entry: Disk full',
);
@@ -276,7 +276,7 @@ describe('MemoryTool', () => {
});
it('should return confirmation details when memory file is not allowlisted', async () => {
const params = { fact: 'Test fact' };
const params = { fact: 'Test fact', scope: 'global' as const };
const result = await memoryTool.shouldConfirmExecute(
params,
mockAbortSignal,
@@ -287,7 +287,9 @@ describe('MemoryTool', () => {
if (result && result.type === 'edit') {
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
expect(result.title).toBe(`Confirm Memory Save: ${expectedPath}`);
expect(result.title).toBe(
`Confirm Memory Save: ${expectedPath} (global)`,
);
expect(result.fileName).toContain(path.join('mock', 'home', '.qwen'));
expect(result.fileName).toContain('QWEN.md');
expect(result.fileDiff).toContain('Index: QWEN.md');
@@ -300,16 +302,16 @@ describe('MemoryTool', () => {
});
it('should return false when memory file is already allowlisted', async () => {
const params = { fact: 'Test fact' };
const params = { fact: 'Test fact', scope: 'global' as const };
const memoryFilePath = path.join(
os.homedir(),
'.qwen',
getCurrentGeminiMdFilename(),
);
// Add the memory file to the allowlist
// Add the memory file to the allowlist with the new key format
(MemoryTool as unknown as { allowlist: Set<string> }).allowlist.add(
memoryFilePath,
`${memoryFilePath}_global`,
);
const result = await memoryTool.shouldConfirmExecute(
@@ -321,7 +323,7 @@ describe('MemoryTool', () => {
});
it('should add memory file to allowlist when ProceedAlways is confirmed', async () => {
const params = { fact: 'Test fact' };
const params = { fact: 'Test fact', scope: 'global' as const };
const memoryFilePath = path.join(
os.homedir(),
'.qwen',
@@ -340,10 +342,10 @@ describe('MemoryTool', () => {
// Simulate the onConfirm callback
await result.onConfirm(ToolConfirmationOutcome.ProceedAlways);
// Check that the memory file was added to the allowlist
// Check that the memory file was added to the allowlist with the new key format
expect(
(MemoryTool as unknown as { allowlist: Set<string> }).allowlist.has(
memoryFilePath,
`${memoryFilePath}_global`,
),
).toBe(true);
}
@@ -384,7 +386,7 @@ describe('MemoryTool', () => {
});
it('should handle existing memory file with content', async () => {
const params = { fact: 'New fact' };
const params = { fact: 'New fact', scope: 'global' as const };
const existingContent =
'Some existing content.\n\n## Qwen Added Memories\n- Old fact\n';
@@ -401,7 +403,9 @@ describe('MemoryTool', () => {
if (result && result.type === 'edit') {
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
expect(result.title).toBe(`Confirm Memory Save: ${expectedPath}`);
expect(result.title).toBe(
`Confirm Memory Save: ${expectedPath} (global)`,
);
expect(result.fileDiff).toContain('Index: QWEN.md');
expect(result.fileDiff).toContain('+- New fact');
expect(result.originalContent).toBe(existingContent);
@@ -409,5 +413,37 @@ describe('MemoryTool', () => {
expect(result.newContent).toContain('- New fact');
}
});
it('should prompt for scope selection when scope is not specified', async () => {
const params = { fact: 'Test fact' };
const result = await memoryTool.shouldConfirmExecute(
params,
mockAbortSignal,
);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
expect(result.title).toBe('Choose Memory Storage Location');
expect(result.fileName).toBe('Memory Storage Options');
expect(result.fileDiff).toContain('Choose where to save this memory');
expect(result.fileDiff).toContain('Test fact');
expect(result.fileDiff).toContain('Global:');
expect(result.fileDiff).toContain('Project:');
expect(result.originalContent).toBe('');
}
});
it('should return error when executing without scope parameter', async () => {
const params = { fact: 'Test fact' };
const result = await memoryTool.execute(params, mockAbortSignal);
expect(result.llmContent).toContain(
'Please specify where to save this memory',
);
expect(result.returnDisplay).toContain('Global:');
expect(result.returnDisplay).toContain('Project:');
});
});
});

View File

@@ -32,6 +32,12 @@ const memoryToolSchemaData: FunctionDeclaration = {
description:
'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
},
scope: {
type: Type.STRING,
description:
'Where to save the memory: "global" saves to user-level ~/.qwen/QWEN.md (shared across all projects), "project" saves to current project\'s QWEN.md (project-specific). If not specified, will prompt user to choose.',
enum: ['global', 'project'],
},
},
required: ['fact'],
},
@@ -54,6 +60,10 @@ Do NOT use this tool:
## Parameters
- \`fact\` (string, required): The specific fact or piece of information to remember. This should be a clear, self-contained statement. For example, if the user says "My favorite color is blue", the fact would be "My favorite color is blue".
- \`scope\` (string, optional): Where to save the memory:
- "global": Saves to user-level ~/.qwen/QWEN.md (shared across all projects)
- "project": Saves to current project's QWEN.md (project-specific)
- If not specified, the tool will ask the user where they want to save the memory.
`;
export const GEMINI_CONFIG_DIR = '.qwen';
@@ -92,12 +102,23 @@ interface SaveMemoryParams {
fact: string;
modified_by_user?: boolean;
modified_content?: string;
scope?: 'global' | 'project';
}
function getGlobalMemoryFilePath(): string {
return path.join(homedir(), GEMINI_CONFIG_DIR, getCurrentGeminiMdFilename());
}
function getProjectMemoryFilePath(): string {
return path.join(process.cwd(), getCurrentGeminiMdFilename());
}
function getMemoryFilePath(scope: 'global' | 'project' = 'global'): string {
return scope === 'project'
? getProjectMemoryFilePath()
: getGlobalMemoryFilePath();
}
/**
* Ensures proper newline separation before appending content.
*/
@@ -127,17 +148,20 @@ export class MemoryTool
);
}
getDescription(_params: SaveMemoryParams): string {
const memoryFilePath = getGlobalMemoryFilePath();
return `in ${tildeifyPath(memoryFilePath)}`;
getDescription(params: SaveMemoryParams): string {
const scope = params.scope || 'global';
const memoryFilePath = getMemoryFilePath(scope);
return `in ${tildeifyPath(memoryFilePath)} (${scope})`;
}
/**
* Reads the current content of the memory file
*/
private async readMemoryFileContent(): Promise<string> {
private async readMemoryFileContent(
scope: 'global' | 'project' = 'global',
): Promise<string> {
try {
return await fs.readFile(getGlobalMemoryFilePath(), 'utf-8');
return await fs.readFile(getMemoryFilePath(scope), 'utf-8');
} catch (err) {
const error = err as Error & { code?: string };
if (!(error instanceof Error) || error.code !== 'ENOENT') throw err;
@@ -193,15 +217,35 @@ export class MemoryTool
params: SaveMemoryParams,
_abortSignal: AbortSignal,
): Promise<ToolEditConfirmationDetails | false> {
const memoryFilePath = getGlobalMemoryFilePath();
const allowlistKey = memoryFilePath;
// If scope is not specified, prompt the user to choose
if (!params.scope) {
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: `Choose Memory Storage Location`,
fileName: 'Memory Storage Options',
fileDiff: `Choose where to save this memory:\n\n"${params.fact}"\n\nOptions:\n- Global: ${globalPath} (shared across all projects)\n- Project: ${projectPath} (current project only)\n\nPlease specify the scope parameter: "global" or "project"`,
originalContent: '',
newContent: `Memory to save: ${params.fact}\n\nScope options:\n- global: ${globalPath}\n- project: ${projectPath}`,
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// This will be handled by the execution flow
},
};
return confirmationDetails;
}
const scope = params.scope;
const memoryFilePath = getMemoryFilePath(scope);
const allowlistKey = `${memoryFilePath}_${scope}`;
if (MemoryTool.allowlist.has(allowlistKey)) {
return false;
}
// Read current content of the memory file
const currentContent = await this.readMemoryFileContent();
const currentContent = await this.readMemoryFileContent(scope);
// Calculate the new content that will be written to the memory file
const newContent = this.computeNewContent(currentContent, params.fact);
@@ -218,7 +262,7 @@ export class MemoryTool
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: `Confirm Memory Save: ${tildeifyPath(memoryFilePath)}`,
title: `Confirm Memory Save: ${tildeifyPath(memoryFilePath)} (${scope})`,
fileName: memoryFilePath,
fileDiff,
originalContent: currentContent,
@@ -316,18 +360,27 @@ export class MemoryTool
};
}
// If scope is not specified, prompt the user to choose
if (!params.scope) {
const errorMessage =
'Please specify where to save this memory. Use scope parameter: "global" for user-level (~/.qwen/QWEN.md) or "project" for current project (./QWEN.md).';
return {
llmContent: JSON.stringify({ success: false, error: errorMessage }),
returnDisplay: `${errorMessage}\n\nGlobal: ${tildeifyPath(getMemoryFilePath('global'))}\nProject: ${tildeifyPath(getMemoryFilePath('project'))}`,
};
}
const scope = params.scope;
const memoryFilePath = getMemoryFilePath(scope);
try {
if (modified_by_user && modified_content !== undefined) {
// User modified the content in external editor, write it directly
await fs.mkdir(path.dirname(getGlobalMemoryFilePath()), {
await fs.mkdir(path.dirname(memoryFilePath), {
recursive: true,
});
await fs.writeFile(
getGlobalMemoryFilePath(),
modified_content,
'utf-8',
);
const successMessage = `Okay, I've updated the memory file with your modifications.`;
await fs.writeFile(memoryFilePath, modified_content, 'utf-8');
const successMessage = `Okay, I've updated the ${scope} memory file with your modifications.`;
return {
llmContent: JSON.stringify({
success: true,
@@ -337,16 +390,12 @@ export class MemoryTool
};
} else {
// Use the normal memory entry logic
await MemoryTool.performAddMemoryEntry(
fact,
getGlobalMemoryFilePath(),
{
readFile: fs.readFile,
writeFile: fs.writeFile,
mkdir: fs.mkdir,
},
);
const successMessage = `Okay, I've remembered that: "${fact}"`;
await MemoryTool.performAddMemoryEntry(fact, memoryFilePath, {
readFile: fs.readFile,
writeFile: fs.writeFile,
mkdir: fs.mkdir,
});
const successMessage = `Okay, I've remembered that in ${scope} memory: "${fact}"`;
return {
llmContent: JSON.stringify({
success: true,
@@ -359,7 +408,7 @@ export class MemoryTool
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[MemoryTool] Error executing save_memory for fact "${fact}": ${errorMessage}`,
`[MemoryTool] Error executing save_memory for fact "${fact}" in ${scope}: ${errorMessage}`,
);
return {
llmContent: JSON.stringify({
@@ -373,11 +422,13 @@ export class MemoryTool
getModifyContext(_abortSignal: AbortSignal): ModifyContext<SaveMemoryParams> {
return {
getFilePath: (_params: SaveMemoryParams) => getGlobalMemoryFilePath(),
getCurrentContent: async (_params: SaveMemoryParams): Promise<string> =>
this.readMemoryFileContent(),
getFilePath: (params: SaveMemoryParams) =>
getMemoryFilePath(params.scope || 'global'),
getCurrentContent: async (params: SaveMemoryParams): Promise<string> =>
this.readMemoryFileContent(params.scope || 'global'),
getProposedContent: async (params: SaveMemoryParams): Promise<string> => {
const currentContent = await this.readMemoryFileContent();
const scope = params.scope || 'global';
const currentContent = await this.readMemoryFileContent(scope);
return this.computeNewContent(currentContent, params.fact);
},
createUpdatedParams: (

View File

@@ -18,24 +18,10 @@ describe('WebFetchTool', () => {
describe('shouldConfirmExecute', () => {
it('should return confirmation details with the correct prompt and urls', async () => {
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://example.com' };
const confirmationDetails = await tool.shouldConfirmExecute(params);
expect(confirmationDetails).toEqual({
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'fetch https://example.com',
urls: ['https://example.com'],
onConfirm: expect.any(Function),
});
});
it('should convert github urls to raw format', async () => {
const tool = new WebFetchTool(mockConfig);
const params = {
prompt:
'fetch https://github.com/google/gemini-react/blob/main/README.md',
url: 'https://example.com',
prompt: 'summarize this page',
};
const confirmationDetails = await tool.shouldConfirmExecute(params);
@@ -43,10 +29,26 @@ describe('WebFetchTool', () => {
type: 'info',
title: 'Confirm Web Fetch',
prompt:
'fetch https://github.com/google/gemini-react/blob/main/README.md',
urls: [
'https://raw.githubusercontent.com/google/gemini-react/main/README.md',
],
'Fetch content from https://example.com and process with: summarize this page',
urls: ['https://example.com'],
onConfirm: expect.any(Function),
});
});
it('should return github urls as-is in confirmation details', async () => {
const tool = new WebFetchTool(mockConfig);
const params = {
url: 'https://github.com/google/gemini-react/blob/main/README.md',
prompt: 'summarize the README',
};
const confirmationDetails = await tool.shouldConfirmExecute(params);
expect(confirmationDetails).toEqual({
type: 'info',
title: 'Confirm Web Fetch',
prompt:
'Fetch content from https://github.com/google/gemini-react/blob/main/README.md and process with: summarize the README',
urls: ['https://github.com/google/gemini-react/blob/main/README.md'],
onConfirm: expect.any(Function),
});
});
@@ -56,7 +58,10 @@ describe('WebFetchTool', () => {
...mockConfig,
getApprovalMode: () => ApprovalMode.AUTO_EDIT,
} as unknown as Config);
const params = { prompt: 'fetch https://example.com' };
const params = {
url: 'https://example.com',
prompt: 'summarize this page',
};
const confirmationDetails = await tool.shouldConfirmExecute(params);
expect(confirmationDetails).toBe(false);
@@ -68,7 +73,10 @@ describe('WebFetchTool', () => {
...mockConfig,
setApprovalMode,
} as unknown as Config);
const params = { prompt: 'fetch https://example.com' };
const params = {
url: 'https://example.com',
prompt: 'summarize this page',
};
const confirmationDetails = await tool.shouldConfirmExecute(params);
if (

View File

@@ -13,49 +13,25 @@ import {
Icon,
} from './tools.js';
import { Type } from '@google/genai';
import { getErrorMessage } from '../utils/errors.js';
import { Config, ApprovalMode } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { fetchWithTimeout } from '../utils/fetch.js';
import { convert } from 'html-to-text';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000;
// Helper function to extract URLs from a string
function extractUrls(text: string): string[] {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.match(urlRegex) || [];
}
// Interfaces for grounding metadata (similar to web-search.ts)
interface GroundingChunkWeb {
uri?: string;
title?: string;
}
interface GroundingChunkItem {
web?: GroundingChunkWeb;
}
interface GroundingSupportSegment {
startIndex: number;
endIndex: number;
text?: string;
}
interface GroundingSupportItem {
segment?: GroundingSupportSegment;
groundingChunkIndices?: number[];
}
/**
* Parameters for the WebFetch tool
*/
export interface WebFetchToolParams {
/**
* The prompt containing URL(s) (up to 20) and instructions for processing their content.
* The URL to fetch content from
*/
url: string;
/**
* The prompt to run on the fetched content
*/
prompt: string;
}
@@ -70,17 +46,20 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
super(
WebFetchTool.Name,
'WebFetch',
"Processes content from URL(s), including local and private network addresses (e.g., localhost), embedded in a prompt. Include up to 20 URLs and instructions (e.g., summarize, extract specific data) directly in the 'prompt' parameter.",
'Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model\'s response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".\n - The URL must be a fully-formed valid URL\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large',
Icon.Globe,
{
properties: {
url: {
description: 'The URL to fetch content from',
type: Type.STRING,
},
prompt: {
description:
'A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). Must contain as least one URL starting with http:// or https://.',
description: 'The prompt to run on the fetched content',
type: Type.STRING,
},
},
required: ['prompt'],
required: ['url', 'prompt'],
type: Type.OBJECT,
},
);
@@ -90,19 +69,11 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
}
}
private async executeFallback(
private async executeFetch(
params: WebFetchToolParams,
signal: AbortSignal,
): Promise<ToolResult> {
const urls = extractUrls(params.prompt);
if (urls.length === 0) {
return {
llmContent: 'Error: No URL found in the prompt for fallback.',
returnDisplay: 'Error: No URL found in the prompt for fallback.',
};
}
// For now, we only support one URL for fallback
let url = urls[0];
let url = params.url;
// Convert GitHub blob URL to raw URL
if (url.includes('github.com') && url.includes('/blob/')) {
@@ -130,7 +101,7 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
const geminiClient = this.config.getGeminiClient();
const fallbackPrompt = `The user requested the following: "${params.prompt}".
I was unable to access the URL directly. Instead, I have fetched the raw content of the page. Please use the following content to answer the user's request. Do not attempt to access the URL again.
I have fetched the content from ${params.url}. Please use the following content to answer the user's request.
---
${textContent}
@@ -143,11 +114,11 @@ ${textContent}
const resultText = getResponseText(result) || '';
return {
llmContent: resultText,
returnDisplay: `Content for ${url} processed using fallback fetch.`,
returnDisplay: `Content from ${params.url} processed successfully.`,
};
} catch (e) {
const error = e as Error;
const errorMessage = `Error during fallback fetch for ${url}: ${error.message}`;
const errorMessage = `Error during fetch for ${url}: ${error.message}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
@@ -160,14 +131,17 @@ ${textContent}
if (errors) {
return errors;
}
if (!params.prompt || params.prompt.trim() === '') {
return "The 'prompt' parameter cannot be empty and must contain URL(s) and instructions.";
if (!params.url || params.url.trim() === '') {
return "The 'url' parameter cannot be empty.";
}
if (
!params.prompt.includes('http://') &&
!params.prompt.includes('https://')
!params.url.startsWith('http://') &&
!params.url.startsWith('https://')
) {
return "The 'prompt' must contain at least one valid URL (starting with http:// or https://).";
return "The 'url' must be a valid URL starting with http:// or https://.";
}
if (!params.prompt || params.prompt.trim() === '') {
return "The 'prompt' parameter cannot be empty.";
}
return null;
}
@@ -177,7 +151,7 @@ ${textContent}
params.prompt.length > 100
? params.prompt.substring(0, 97) + '...'
: params.prompt;
return `Processing URLs and instructions from prompt: "${displayPrompt}"`;
return `Fetching content from ${params.url} and processing with prompt: "${displayPrompt}"`;
}
async shouldConfirmExecute(
@@ -192,22 +166,11 @@ ${textContent}
return false;
}
// Perform GitHub URL conversion here to differentiate between user-provided
// URL and the actual URL to be fetched.
const urls = extractUrls(params.prompt).map((url) => {
if (url.includes('github.com') && url.includes('/blob/')) {
return url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
}
return url;
});
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: `Confirm Web Fetch`,
prompt: params.prompt,
urls,
prompt: `Fetch content from ${params.url} and process with: ${params.prompt}`,
urls: [params.url],
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
@@ -229,132 +192,6 @@ ${textContent}
};
}
const userPrompt = params.prompt;
const urls = extractUrls(userPrompt);
const url = urls[0];
const isPrivate = isPrivateIp(url);
if (isPrivate) {
return this.executeFallback(params, signal);
}
const geminiClient = this.config.getGeminiClient();
try {
const response = await geminiClient.generateContent(
[{ role: 'user', parts: [{ text: userPrompt }] }],
{ tools: [{ urlContext: {} }] },
signal, // Pass signal
);
console.debug(
`[WebFetchTool] Full response for prompt "${userPrompt.substring(
0,
50,
)}...":`,
JSON.stringify(response, null, 2),
);
let responseText = getResponseText(response) || '';
const urlContextMeta = response.candidates?.[0]?.urlContextMetadata;
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
const sources = groundingMetadata?.groundingChunks as
| GroundingChunkItem[]
| undefined;
const groundingSupports = groundingMetadata?.groundingSupports as
| GroundingSupportItem[]
| undefined;
// Error Handling
let processingError = false;
if (
urlContextMeta?.urlMetadata &&
urlContextMeta.urlMetadata.length > 0
) {
const allStatuses = urlContextMeta.urlMetadata.map(
(m) => m.urlRetrievalStatus,
);
if (allStatuses.every((s) => s !== 'URL_RETRIEVAL_STATUS_SUCCESS')) {
processingError = true;
}
} else if (!responseText.trim() && !sources?.length) {
// No URL metadata and no content/sources
processingError = true;
}
if (
!processingError &&
!responseText.trim() &&
(!sources || sources.length === 0)
) {
// Successfully retrieved some URL (or no specific error from urlContextMeta), but no usable text or grounding data.
processingError = true;
}
if (processingError) {
return this.executeFallback(params, signal);
}
const sourceListFormatted: string[] = [];
if (sources && sources.length > 0) {
sources.forEach((source: GroundingChunkItem, index: number) => {
const title = source.web?.title || 'Untitled';
const uri = source.web?.uri || 'Unknown URI'; // Fallback if URI is missing
sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
});
if (groundingSupports && groundingSupports.length > 0) {
const insertions: Array<{ index: number; marker: string }> = [];
groundingSupports.forEach((support: GroundingSupportItem) => {
if (support.segment && support.groundingChunkIndices) {
const citationMarker = support.groundingChunkIndices
.map((chunkIndex: number) => `[${chunkIndex + 1}]`)
.join('');
insertions.push({
index: support.segment.endIndex,
marker: citationMarker,
});
}
});
insertions.sort((a, b) => b.index - a.index);
const responseChars = responseText.split('');
insertions.forEach((insertion) => {
responseChars.splice(insertion.index, 0, insertion.marker);
});
responseText = responseChars.join('');
}
if (sourceListFormatted.length > 0) {
responseText += `
Sources:
${sourceListFormatted.join('\n')}`;
}
}
const llmContent = responseText;
console.debug(
`[WebFetchTool] Formatted tool response for prompt "${userPrompt}:\n\n":`,
llmContent,
);
return {
llmContent,
returnDisplay: `Content processed from prompt.`,
};
} catch (error: unknown) {
const errorMessage = `Error processing web content for prompt "${userPrompt.substring(
0,
50,
)}...": ${getErrorMessage(error)}`;
console.error(errorMessage, error);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
};
}
return this.executeFetch(params, signal);
}
}

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.0.6",
"version": "0.0.7",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {