diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 9ee33e69..684f61e9 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(); @@ -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), ); @@ -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', () => { @@ -173,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 dd34d92c..7e373252 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -7,7 +7,11 @@ import { getErrorMessage, loadServerHierarchicalMemory, + QWEN_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,24 +45,136 @@ export const memoryCommand: SlashCommand = { Date.now(), ); }, + subCommands: [ + { + name: '--project', + description: 'Show project-level memory contents.', + kind: CommandKind.BUILT_IN, + action: async (context) => { + try { + const projectMemoryPath = path.join(process.cwd(), 'QWEN.md'); + const memoryContent = await fs.readFile( + projectMemoryPath, + 'utf-8', + ); + + 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(), + ); + } catch (_error) { + context.ui.addItem( + { + type: MessageType.INFO, + text: 'Project memory file not found or is currently empty.', + }, + Date.now(), + ); + } + }, + }, + { + name: '--global', + description: 'Show global memory contents.', + kind: CommandKind.BUILT_IN, + action: async (context) => { + try { + const globalMemoryPath = path.join( + os.homedir(), + QWEN_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', - 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(), ); @@ -66,9 +182,67 @@ export const memoryCommand: SlashCommand = { return { type: 'tool', toolName: 'save_memory', - toolArgs: { fact: args.trim() }, + 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', @@ -84,7 +258,7 @@ export const memoryCommand: SlashCommand = { ); try { - const config = await context.services.config; + const config = context.services.config; if (config) { const { memoryContent, fileCount } = await loadServerHierarchicalMemory( 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/tools/memoryTool.test.ts b/packages/core/src/tools/memoryTool.test.ts index 1e236f7e..b66d60bf 100644 --- a/packages/core/src/tools/memoryTool.test.ts +++ b/packages/core/src/tools/memoryTool.test.ts @@ -522,13 +522,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:'); } }); @@ -577,13 +580,16 @@ describe('MemoryTool', () => { expect(description).toBe(`${expectedPath} (project)`); }); - it('should default to global scope when scope is not specified', () => { + it('should show choice prompt when scope is not specified', () => { const params = { fact: 'Test fact' }; const invocation = memoryTool.build(params); const description = invocation.getDescription(); - const expectedPath = path.join('~', '.qwen', 'QWEN.md'); - expect(description).toBe(`${expectedPath} (global)`); + const globalPath = path.join('~', '.qwen', 'QWEN.md'); + const projectPath = path.join(process.cwd(), 'QWEN.md'); + expect(description).toBe( + `CHOOSE: ${globalPath} (global) OR ${projectPath} (project)`, + ); }); }); }); diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 7b4beed6..7cc6c835 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -199,7 +199,12 @@ class MemoryToolInvocation extends BaseToolInvocation< private static readonly allowlist: Set = new Set(); getDescription(): string { - const scope = this.params.scope || 'global'; + if (!this.params.scope) { + const globalPath = tildeifyPath(getMemoryFilePath('global')); + const projectPath = tildeifyPath(getMemoryFilePath('project')); + return `CHOOSE: ${globalPath} (global) OR ${projectPath} (project)`; + } + const scope = this.params.scope; const memoryFilePath = getMemoryFilePath(scope); return `${tildeifyPath(memoryFilePath)} (${scope})`; } @@ -207,26 +212,54 @@ class MemoryToolInvocation extends BaseToolInvocation< override async shouldConfirmExecute( _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 (!this.params.scope) { + // Show preview of what would be added to global by default + const defaultScope = 'global'; + const currentContent = await readMemoryFileContent(defaultScope); + const newContent = computeNewContent(currentContent, this.params.fact); + const globalPath = tildeifyPath(getMemoryFilePath('global')); const projectPath = tildeifyPath(getMemoryFilePath('project')); + const fileName = path.basename(getMemoryFilePath(defaultScope)); + const choiceText = `Choose where to save this memory: + +"${this.params.fact}" + +Options: +- Global: ${globalPath} (shared across all projects) +- Project: ${projectPath} (current project only) + +Preview of changes to be made to GLOBAL memory: +`; + const fileDiff = + choiceText + + Diff.createPatch( + fileName, + currentContent, + newContent, + 'Current', + 'Proposed (Global)', + DEFAULT_DIFF_OPTIONS, + ); + const confirmationDetails: ToolEditConfirmationDetails = { type: 'edit', - title: `Choose Memory Storage Location`, - fileName: 'Memory Storage Options', - filePath: '', - fileDiff: `Choose where to save this memory:\n\n"${this.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: ${this.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; } + // Only check allowlist when scope is specified const scope = this.params.scope; const memoryFilePath = getMemoryFilePath(scope); const allowlistKey = `${memoryFilePath}_${scope}`; @@ -279,17 +312,25 @@ class MemoryToolInvocation extends BaseToolInvocation< }; } - // If scope is not specified, prompt the user to choose - if (!this.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 (!this.params.scope && !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 = this.params.scope; + const scope = this.params.scope || 'global'; const memoryFilePath = getMemoryFilePath(scope); try { @@ -447,24 +488,88 @@ export class MemoryTool getModifyContext(_abortSignal: AbortSignal): ModifyContext { return { - getFilePath: (params: SaveMemoryParams) => - getMemoryFilePath(params.scope || 'global'), - getCurrentContent: async (params: SaveMemoryParams): Promise => - 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 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 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 readMemoryFileContent(scope); - return computeNewContent(currentContent, params.fact); + const newContent = 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, + }; + }, }; } } 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() {