From d7f7580a3082ab54f48cd5ee5d5fba22c121acb2 Mon Sep 17 00:00:00 2001 From: "koalazf.99" Date: Mon, 18 Aug 2025 22:34:08 +0800 Subject: [PATCH 1/5] support: qwen md selection --- 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: ( From 300881405a04a3f7ba1380a156f08d0c961b6e5a Mon Sep 17 00:00:00 2001 From: koalazf99 Date: Sun, 24 Aug 2025 00:31:10 +0800 Subject: [PATCH 2/5] tmp --- package.json | 4 +- .../cli/src/ui/commands/memoryCommand.test.ts | 57 ++++++- packages/cli/src/ui/commands/memoryCommand.ts | 45 +++++- packages/core/src/tools/memoryTool.test.ts | 15 +- packages/core/src/tools/memoryTool.ts | 149 ++++++++++++++---- 5 files changed, 229 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 56116b52..4b1cd151 100644 --- a/package.json +++ b/package.json @@ -80,13 +80,13 @@ "json": "^11.0.0", "lodash": "^4.17.21", "memfs": "^4.17.2", + "mnemonist": "^0.40.3", "mock-fs": "^5.5.0", "prettier": "^3.5.3", "react-devtools-core": "^4.28.5", "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", - "yargs": "^17.7.2", - "mnemonist": "^0.40.3" + "yargs": "^17.7.2" } } diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 9ee33e69..77281110 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -117,7 +117,7 @@ describe('memoryCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Usage: /memory add ', + content: 'Usage: /memory add [--global|--project] ', }); expect(mockContext.ui.addItem).not.toHaveBeenCalled(); @@ -143,6 +143,61 @@ describe('memoryCommand', () => { toolArgs: { fact }, }); }); + + it('should handle --global flag and add scope to tool args', () => { + if (!addCommand.action) throw new Error('Command has no action'); + + const fact = 'remember this globally'; + const result = addCommand.action(mockContext, `--global ${fact}`); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Attempting to save to memory (global): "${fact}"`, + }, + expect.any(Number), + ); + + expect(result).toEqual({ + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact, scope: 'global' }, + }); + }); + + it('should handle --project flag and add scope to tool args', () => { + if (!addCommand.action) throw new Error('Command has no action'); + + const fact = 'remember this for project'; + const result = addCommand.action(mockContext, `--project ${fact}`); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Attempting to save to memory (project): "${fact}"`, + }, + expect.any(Number), + ); + + expect(result).toEqual({ + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact, scope: 'project' }, + }); + }); + + it('should return error if flag is provided but no fact follows', () => { + if (!addCommand.action) throw new Error('Command has no action'); + + const result = addCommand.action(mockContext, '--global '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /memory add [--global|--project] ', + }); + + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + }); }); describe('/memory refresh', () => { diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index dd34d92c..8b742ef3 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -44,29 +44,66 @@ export const memoryCommand: SlashCommand = { }, { name: 'add', - description: 'Add content to the memory.', + description: + 'Add content to the memory. Use --global for global memory or --project for project memory.', kind: CommandKind.BUILT_IN, action: (context, args): SlashCommandActionReturn | void => { if (!args || args.trim() === '') { return { type: 'message', messageType: 'error', - content: 'Usage: /memory add ', + content: + 'Usage: /memory add [--global|--project] ', }; } + const trimmedArgs = args.trim(); + let scope: 'global' | 'project' | undefined; + let fact: string; + + // Check for scope flags + if (trimmedArgs.startsWith('--global ')) { + scope = 'global'; + fact = trimmedArgs.substring('--global '.length).trim(); + } else if (trimmedArgs.startsWith('--project ')) { + scope = 'project'; + fact = trimmedArgs.substring('--project '.length).trim(); + } else if (trimmedArgs === '--global' || trimmedArgs === '--project') { + // Flag provided but no text after it + return { + type: 'message', + messageType: 'error', + content: + 'Usage: /memory add [--global|--project] ', + }; + } else { + // No scope specified, will be handled by the tool + fact = trimmedArgs; + } + + if (!fact || fact.trim() === '') { + return { + type: 'message', + messageType: 'error', + content: + 'Usage: /memory add [--global|--project] ', + }; + } + + const scopeText = scope ? ` (${scope})` : ''; context.ui.addItem( { type: MessageType.INFO, - text: `Attempting to save to memory: "${args.trim()}"`, + text: `Attempting to save to memory${scopeText}: "${fact}"`, }, Date.now(), ); + const toolArgs = scope ? { fact, scope } : { fact }; return { type: 'tool', toolName: 'save_memory', - toolArgs: { fact: args.trim() }, + toolArgs, }; }, }, diff --git a/packages/core/src/tools/memoryTool.test.ts b/packages/core/src/tools/memoryTool.test.ts index b01471f7..b78ff10b 100644 --- a/packages/core/src/tools/memoryTool.test.ts +++ b/packages/core/src/tools/memoryTool.test.ts @@ -425,13 +425,16 @@ describe('MemoryTool', () => { 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.title).toContain('Choose Memory Location'); + expect(result.title).toContain('GLOBAL'); + expect(result.title).toContain('PROJECT'); + expect(result.fileName).toBe('QWEN.md'); expect(result.fileDiff).toContain('Test fact'); - expect(result.fileDiff).toContain('Global:'); - expect(result.fileDiff).toContain('Project:'); - expect(result.originalContent).toBe(''); + expect(result.fileDiff).toContain('--- QWEN.md'); + expect(result.fileDiff).toContain('+++ QWEN.md'); + expect(result.fileDiff).toContain('+- Test fact'); + expect(result.originalContent).toContain('scope: global'); + expect(result.originalContent).toContain('INSTRUCTIONS:'); } }); diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 4b8fe065..fb1abf33 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -149,7 +149,12 @@ export class MemoryTool } getDescription(params: SaveMemoryParams): string { - const scope = params.scope || 'global'; + if (!params.scope) { + const globalPath = tildeifyPath(getMemoryFilePath('global')); + const projectPath = tildeifyPath(getMemoryFilePath('project')); + return `CHOOSE: ${globalPath} (global) OR ${projectPath} (project)`; + } + const scope = params.scope; const memoryFilePath = getMemoryFilePath(scope); return `in ${tildeifyPath(memoryFilePath)} (${scope})`; } @@ -217,27 +222,43 @@ export class MemoryTool params: SaveMemoryParams, _abortSignal: AbortSignal, ): Promise { - // If scope is not specified, prompt the user to choose + // When scope is not specified, show a choice dialog defaulting to global if (!params.scope) { + // Show preview of what would be added to global by default + const defaultScope = 'global'; + const currentContent = await this.readMemoryFileContent(defaultScope); + const newContent = this.computeNewContent(currentContent, params.fact); + + const fileName = path.basename(getMemoryFilePath(defaultScope)); + const fileDiff = Diff.createPatch( + fileName, + currentContent, + newContent, + 'Current', + 'Proposed (Global)', + DEFAULT_DIFF_OPTIONS, + ); + const globalPath = tildeifyPath(getMemoryFilePath('global')); const projectPath = tildeifyPath(getMemoryFilePath('project')); const confirmationDetails: ToolEditConfirmationDetails = { type: 'edit', - title: `Choose Memory Storage Location`, - fileName: 'Memory Storage Options', - filePath: '', - 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}`, + title: `Choose Memory Location: GLOBAL (${globalPath}) or PROJECT (${projectPath})`, + fileName, + filePath: getMemoryFilePath(defaultScope), + fileDiff, + originalContent: `scope: global\n\n# INSTRUCTIONS:\n# - Click "Yes" to save to GLOBAL memory: ${globalPath}\n# - Click "Modify with external editor" and change "global" to "project" to save to PROJECT memory: ${projectPath}\n\n${currentContent}`, + newContent: `scope: global\n\n# INSTRUCTIONS:\n# - Click "Yes" to save to GLOBAL memory: ${globalPath}\n# - Click "Modify with external editor" and change "global" to "project" to save to PROJECT memory: ${projectPath}\n\n${newContent}`, onConfirm: async (_outcome: ToolConfirmationOutcome) => { - // This will be handled by the execution flow + // Will be handled in createUpdatedParams }, }; return confirmationDetails; } - const scope = params.scope; + // Only check allowlist when scope is specified + const scope = params.scope!; // We know scope is specified at this point const memoryFilePath = getMemoryFilePath(scope); const allowlistKey = `${memoryFilePath}_${scope}`; @@ -362,17 +383,25 @@ 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).'; + // If scope is not specified and user didn't modify content, return error prompting for choice + if (!params.scope && !params.modified_by_user) { + const globalPath = tildeifyPath(getMemoryFilePath('global')); + const projectPath = tildeifyPath(getMemoryFilePath('project')); + const errorMessage = `Please specify where to save this memory: + +Global: ${globalPath} (shared across all projects) +Project: ${projectPath} (current project only)`; + return { - llmContent: JSON.stringify({ success: false, error: errorMessage }), - returnDisplay: `${errorMessage}\n\nGlobal: ${tildeifyPath(getMemoryFilePath('global'))}\nProject: ${tildeifyPath(getMemoryFilePath('project'))}`, + llmContent: JSON.stringify({ + success: false, + error: 'Please specify where to save this memory', + }), + returnDisplay: errorMessage, }; } - const scope = params.scope; + const scope = params.scope || 'global'; const memoryFilePath = getMemoryFilePath(scope); try { @@ -424,24 +453,88 @@ export class MemoryTool getModifyContext(_abortSignal: AbortSignal): ModifyContext { return { - getFilePath: (params: SaveMemoryParams) => - getMemoryFilePath(params.scope || 'global'), - getCurrentContent: async (params: SaveMemoryParams): Promise => - this.readMemoryFileContent(params.scope || 'global'), - getProposedContent: async (params: SaveMemoryParams): Promise => { + getFilePath: (params: SaveMemoryParams) => { + // Determine scope from modified content or default + let scope = params.scope || 'global'; + if (params.modified_content) { + const scopeMatch = params.modified_content.match( + /^scope:\s*(global|project)\s*\n/i, + ); + if (scopeMatch) { + scope = scopeMatch[1].toLowerCase() as 'global' | 'project'; + } + } + return getMemoryFilePath(scope); + }, + getCurrentContent: async (params: SaveMemoryParams): Promise => { + // Check if content starts with scope directive + if (params.modified_content) { + const scopeMatch = params.modified_content.match( + /^scope:\s*(global|project)\s*\n/i, + ); + if (scopeMatch) { + const scope = scopeMatch[1].toLowerCase() as 'global' | 'project'; + const content = await this.readMemoryFileContent(scope); + const globalPath = tildeifyPath(getMemoryFilePath('global')); + const projectPath = tildeifyPath(getMemoryFilePath('project')); + return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${content}`; + } + } const scope = params.scope || 'global'; + const content = await this.readMemoryFileContent(scope); + const globalPath = tildeifyPath(getMemoryFilePath('global')); + const projectPath = tildeifyPath(getMemoryFilePath('project')); + return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${content}`; + }, + getProposedContent: async (params: SaveMemoryParams): Promise => { + let scope = params.scope || 'global'; + + // Check if modified content has scope directive + if (params.modified_content) { + const scopeMatch = params.modified_content.match( + /^scope:\s*(global|project)\s*\n/i, + ); + if (scopeMatch) { + scope = scopeMatch[1].toLowerCase() as 'global' | 'project'; + } + } + const currentContent = await this.readMemoryFileContent(scope); - return this.computeNewContent(currentContent, params.fact); + const newContent = this.computeNewContent(currentContent, params.fact); + const globalPath = tildeifyPath(getMemoryFilePath('global')); + const projectPath = tildeifyPath(getMemoryFilePath('project')); + return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${newContent}`; }, createUpdatedParams: ( _oldContent: string, modifiedProposedContent: string, originalParams: SaveMemoryParams, - ): SaveMemoryParams => ({ - ...originalParams, - modified_by_user: true, - modified_content: modifiedProposedContent, - }), + ): SaveMemoryParams => { + // Parse user's scope choice from modified content + const scopeMatch = modifiedProposedContent.match( + /^scope:\s*(global|project)/i, + ); + const scope = scopeMatch + ? (scopeMatch[1].toLowerCase() as 'global' | 'project') + : 'global'; + + // Strip out the scope directive and instruction lines, keep only the actual memory content + const contentWithoutScope = modifiedProposedContent.replace( + /^scope:\s*(global|project)\s*\n/, + '', + ); + const actualContent = contentWithoutScope + .replace(/^#[^\n]*\n/gm, '') + .replace(/^\s*\n/gm, '') + .trim(); + + return { + ...originalParams, + scope, + modified_by_user: true, + modified_content: actualContent, + }; + }, }; } } From 380afc53cbf41b136614d0c1af7356c6a055a7ce Mon Sep 17 00:00:00 2001 From: "koalazf.99" Date: Tue, 26 Aug 2025 13:18:11 +0800 Subject: [PATCH 3/5] update: use sub-command to switch between project and global memory ops --- .../cli/src/ui/commands/memoryCommand.test.ts | 4 +- packages/cli/src/ui/commands/memoryCommand.ts | 136 +++++++++++++++++- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 77281110..684f61e9 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -132,7 +132,7 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: `Attempting to save to memory: "${fact}"`, + text: `Attempting to save to memory : "${fact}"`, }, expect.any(Number), ); @@ -228,7 +228,7 @@ describe('memoryCommand', () => { mockContext = createMockCommandContext({ services: { - config: Promise.resolve(mockConfig), + config: mockConfig, settings: { merged: { memoryDiscoveryMaxDirs: 1000, diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 8b742ef3..2c1bda37 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -7,7 +7,11 @@ import { getErrorMessage, loadServerHierarchicalMemory, + GEMINI_DIR, } from '@qwen-code/qwen-code-core'; +import path from 'node:path'; +import os from 'os'; +import fs from 'fs/promises'; import { MessageType } from '../types.js'; import { CommandKind, @@ -41,6 +45,71 @@ export const memoryCommand: SlashCommand = { Date.now(), ); }, + subCommands: [ + { + name: '--project', + description: 'Show project-level memory contents.', + kind: CommandKind.BUILT_IN, + action: async (context) => { + const memoryContent = + context.services.config?.getUserMemory() || ''; + const fileCount = + context.services.config?.getGeminiMdFileCount() || 0; + + const messageContent = + memoryContent.length > 0 + ? `Project memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---` + : 'Project memory is currently empty.'; + + context.ui.addItem( + { + type: MessageType.INFO, + text: messageContent, + }, + Date.now(), + ); + }, + }, + { + name: '--global', + description: 'Show global memory contents.', + kind: CommandKind.BUILT_IN, + action: async (context) => { + try { + const globalMemoryPath = path.join( + os.homedir(), + GEMINI_DIR, + 'QWEN.md', + ); + const globalMemoryContent = await fs.readFile( + globalMemoryPath, + 'utf-8', + ); + + const messageContent = + globalMemoryContent.trim().length > 0 + ? `Global memory content:\n\n---\n${globalMemoryContent}\n---` + : 'Global memory is currently empty.'; + + context.ui.addItem( + { + type: MessageType.INFO, + text: messageContent, + }, + Date.now(), + ); + } catch (_error) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'Global memory file not found or is currently empty.', + }, + Date.now(), + ); + } + }, + }, + ], }, { name: 'add', @@ -90,22 +159,79 @@ export const memoryCommand: SlashCommand = { }; } - const scopeText = scope ? ` (${scope})` : ''; + const scopeText = scope ? `(${scope})` : ''; context.ui.addItem( { type: MessageType.INFO, - text: `Attempting to save to memory${scopeText}: "${fact}"`, + text: `Attempting to save to memory ${scopeText}: "${fact}"`, }, Date.now(), ); - const toolArgs = scope ? { fact, scope } : { fact }; return { type: 'tool', toolName: 'save_memory', - toolArgs, + toolArgs: scope ? { fact, scope } : { fact }, }; }, + subCommands: [ + { + name: '--project', + description: 'Add content to project-level memory.', + kind: CommandKind.BUILT_IN, + action: (context, args): SlashCommandActionReturn | void => { + if (!args || args.trim() === '') { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /memory add --project ', + }; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: `Attempting to save to project memory: "${args.trim()}"`, + }, + Date.now(), + ); + + return { + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact: args.trim(), scope: 'project' }, + }; + }, + }, + { + name: '--global', + description: 'Add content to global memory.', + kind: CommandKind.BUILT_IN, + action: (context, args): SlashCommandActionReturn | void => { + if (!args || args.trim() === '') { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /memory add --global ', + }; + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: `Attempting to save to global memory: "${args.trim()}"`, + }, + Date.now(), + ); + + return { + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact: args.trim(), scope: 'global' }, + }; + }, + }, + ], }, { name: 'refresh', @@ -121,7 +247,7 @@ export const memoryCommand: SlashCommand = { ); try { - const config = await context.services.config; + const config = context.services.config; if (config) { const { memoryContent, fileCount } = await loadServerHierarchicalMemory( From f73d662260c1b94f0405d3ef76d5238a47676412 Mon Sep 17 00:00:00 2001 From: "koalazf.99" Date: Tue, 26 Aug 2025 19:44:02 +0800 Subject: [PATCH 4/5] update: remove context.services.config?.getUserMemory() logic from project level memory show --- packages/cli/src/ui/commands/memoryCommand.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 2c1bda37..ec778f7a 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -51,23 +51,34 @@ export const memoryCommand: SlashCommand = { description: 'Show project-level memory contents.', kind: CommandKind.BUILT_IN, action: async (context) => { - const memoryContent = - context.services.config?.getUserMemory() || ''; - const fileCount = - context.services.config?.getGeminiMdFileCount() || 0; + try { + const projectMemoryPath = path.join(process.cwd(), 'QWEN.md'); + const memoryContent = await fs.readFile( + projectMemoryPath, + 'utf-8', + ); - const messageContent = - memoryContent.length > 0 - ? `Project memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---` - : 'Project memory is currently empty.'; + const messageContent = + memoryContent.trim().length > 0 + ? `Project memory content from ${projectMemoryPath}:\n\n---\n${memoryContent}\n---` + : 'Project memory is currently empty.'; - context.ui.addItem( - { - type: MessageType.INFO, - text: messageContent, - }, - Date.now(), - ); + context.ui.addItem( + { + type: MessageType.INFO, + text: messageContent, + }, + Date.now(), + ); + } catch (_error) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'Project memory file not found or is currently empty.', + }, + Date.now(), + ); + } }, }, { From 5cd33497738a0d8187af8f1503fab91f6be18638 Mon Sep 17 00:00:00 2001 From: "koalazf.99" Date: Tue, 26 Aug 2025 20:14:10 +0800 Subject: [PATCH 5/5] rename GEMINI_DIR to QWEN_DIR --- packages/cli/src/ui/commands/memoryCommand.ts | 4 ++-- packages/core/src/services/gitService.test.ts | 4 ++-- packages/core/src/services/gitService.ts | 4 ++-- packages/core/src/utils/paths.ts | 8 ++++---- packages/core/src/utils/user_account.ts | 4 ++-- packages/core/src/utils/user_id.ts | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index ec778f7a..7e373252 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -7,7 +7,7 @@ import { getErrorMessage, loadServerHierarchicalMemory, - GEMINI_DIR, + QWEN_DIR, } from '@qwen-code/qwen-code-core'; import path from 'node:path'; import os from 'os'; @@ -89,7 +89,7 @@ export const memoryCommand: SlashCommand = { try { const globalMemoryPath = path.join( os.homedir(), - GEMINI_DIR, + QWEN_DIR, 'QWEN.md', ); const globalMemoryContent = await fs.readFile( diff --git a/packages/core/src/services/gitService.test.ts b/packages/core/src/services/gitService.test.ts index 9820ba5f..3e1e63f7 100644 --- a/packages/core/src/services/gitService.test.ts +++ b/packages/core/src/services/gitService.test.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import * as fs from 'fs/promises'; import * as os from 'os'; import type { ChildProcess } from 'node:child_process'; -import { getProjectHash, GEMINI_DIR } from '../utils/paths.js'; +import { getProjectHash, QWEN_DIR } from '../utils/paths.js'; const hoistedMockExec = vi.hoisted(() => vi.fn()); vi.mock('node:child_process', () => ({ @@ -157,7 +157,7 @@ describe('GitService', () => { let gitConfigPath: string; beforeEach(() => { - repoDir = path.join(homedir, GEMINI_DIR, 'history', hash); + repoDir = path.join(homedir, QWEN_DIR, 'history', hash); gitConfigPath = path.join(repoDir, '.gitconfig'); }); diff --git a/packages/core/src/services/gitService.ts b/packages/core/src/services/gitService.ts index 8b3fe46f..30f67cf7 100644 --- a/packages/core/src/services/gitService.ts +++ b/packages/core/src/services/gitService.ts @@ -10,7 +10,7 @@ import * as os from 'os'; import { isNodeError } from '../utils/errors.js'; import { exec } from 'node:child_process'; import { simpleGit, SimpleGit, CheckRepoActions } from 'simple-git'; -import { getProjectHash, GEMINI_DIR } from '../utils/paths.js'; +import { getProjectHash, QWEN_DIR } from '../utils/paths.js'; export class GitService { private projectRoot: string; @@ -21,7 +21,7 @@ export class GitService { private getHistoryDir(): string { const hash = getProjectHash(this.projectRoot); - return path.join(os.homedir(), GEMINI_DIR, 'history', hash); + return path.join(os.homedir(), QWEN_DIR, 'history', hash); } async initialize(): Promise { diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 52c578cd..2b512c47 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import os from 'os'; import * as crypto from 'crypto'; -export const GEMINI_DIR = '.qwen'; +export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; const TMP_DIR_NAME = 'tmp'; const COMMANDS_DIR_NAME = 'commands'; @@ -181,7 +181,7 @@ export function getProjectHash(projectRoot: string): string { */ export function getProjectTempDir(projectRoot: string): string { const hash = getProjectHash(projectRoot); - return path.join(os.homedir(), GEMINI_DIR, TMP_DIR_NAME, hash); + return path.join(os.homedir(), QWEN_DIR, TMP_DIR_NAME, hash); } /** @@ -189,7 +189,7 @@ export function getProjectTempDir(projectRoot: string): string { * @returns The path to the user's commands directory. */ export function getUserCommandsDir(): string { - return path.join(os.homedir(), GEMINI_DIR, COMMANDS_DIR_NAME); + return path.join(os.homedir(), QWEN_DIR, COMMANDS_DIR_NAME); } /** @@ -198,5 +198,5 @@ export function getUserCommandsDir(): string { * @returns The path to the project's commands directory. */ export function getProjectCommandsDir(projectRoot: string): string { - return path.join(projectRoot, GEMINI_DIR, COMMANDS_DIR_NAME); + return path.join(projectRoot, QWEN_DIR, COMMANDS_DIR_NAME); } diff --git a/packages/core/src/utils/user_account.ts b/packages/core/src/utils/user_account.ts index 6701dfe3..4f788fce 100644 --- a/packages/core/src/utils/user_account.ts +++ b/packages/core/src/utils/user_account.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { promises as fsp, existsSync, readFileSync } from 'node:fs'; import * as os from 'os'; -import { GEMINI_DIR, GOOGLE_ACCOUNTS_FILENAME } from './paths.js'; +import { QWEN_DIR, GOOGLE_ACCOUNTS_FILENAME } from './paths.js'; interface UserAccounts { active: string | null; @@ -15,7 +15,7 @@ interface UserAccounts { } function getGoogleAccountsCachePath(): string { - return path.join(os.homedir(), GEMINI_DIR, GOOGLE_ACCOUNTS_FILENAME); + return path.join(os.homedir(), QWEN_DIR, GOOGLE_ACCOUNTS_FILENAME); } async function readAccounts(filePath: string): Promise { diff --git a/packages/core/src/utils/user_id.ts b/packages/core/src/utils/user_id.ts index 6f16806f..50e73efe 100644 --- a/packages/core/src/utils/user_id.ts +++ b/packages/core/src/utils/user_id.ts @@ -8,10 +8,10 @@ import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; import { randomUUID } from 'crypto'; -import { GEMINI_DIR } from './paths.js'; +import { QWEN_DIR } from './paths.js'; const homeDir = os.homedir() ?? ''; -const geminiDir = path.join(homeDir, GEMINI_DIR); +const geminiDir = path.join(homeDir, QWEN_DIR); const installationIdFile = path.join(geminiDir, 'installation_id'); function ensureGeminiDirExists() {