From a5a3da01f6586c1abbb8bb9e1a07a965aa4a4c2e Mon Sep 17 00:00:00 2001 From: neo Date: Fri, 1 Aug 2025 15:18:26 +0800 Subject: [PATCH 1/2] 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. --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index a4c78c4d..cdbc94f4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,20 @@ +
+ + + Deutsch | + Español | + français | + 日本語 | + 한국어 | + Português | + Русский | + 中文 + +
+ 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. > [!WARNING] From 7b378e826c7fd2a110762d09e243ede81e7efade Mon Sep 17 00:00:00 2001 From: Fan Date: Mon, 18 Aug 2025 23:09:50 +0800 Subject: [PATCH 2/2] feat: project/global save location option (#368) --- packages/core/src/tools/memoryTool.test.ts | 62 ++++++++--- packages/core/src/tools/memoryTool.ts | 113 +++++++++++++++------ 2 files changed, 131 insertions(+), 44 deletions(-) diff --git a/packages/core/src/tools/memoryTool.test.ts b/packages/core/src/tools/memoryTool.test.ts index 75a2c08a..edae8c42 100644 --- a/packages/core/src/tools/memoryTool.test.ts +++ b/packages/core/src/tools/memoryTool.test.ts @@ -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 }).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 }).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:'); + }); }); }); diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 2b20735d..b0f4bb5e 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -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 { + private async readMemoryFileContent( + scope: 'global' | 'project' = 'global', + ): Promise { 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 { - 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 { return { - getFilePath: (_params: SaveMemoryParams) => getGlobalMemoryFilePath(), - getCurrentContent: async (_params: SaveMemoryParams): Promise => - this.readMemoryFileContent(), + getFilePath: (params: SaveMemoryParams) => + getMemoryFilePath(params.scope || 'global'), + getCurrentContent: async (params: SaveMemoryParams): Promise => + this.readMemoryFileContent(params.scope || 'global'), getProposedContent: async (params: SaveMemoryParams): Promise => { - const currentContent = await this.readMemoryFileContent(); + const scope = params.scope || 'global'; + const currentContent = await this.readMemoryFileContent(scope); return this.computeNewContent(currentContent, params.fact); }, createUpdatedParams: (