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, + }; + }, }; } }