diff --git a/packages/a2a-server/src/commands/command-registry.ts b/packages/a2a-server/src/commands/command-registry.ts index 7b19d5d1f5..e9cd75b11a 100644 --- a/packages/a2a-server/src/commands/command-registry.ts +++ b/packages/a2a-server/src/commands/command-registry.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { MemoryCommand } from './memory.js'; import { debugLogger } from '@google/gemini-cli-core'; import { ExtensionsCommand } from './extensions.js'; import { InitCommand } from './init.js'; @@ -22,6 +23,7 @@ export class CommandRegistry { this.register(new ExtensionsCommand()); this.register(new RestoreCommand()); this.register(new InitCommand()); + this.register(new MemoryCommand()); } register(command: Command) { diff --git a/packages/a2a-server/src/commands/memory.test.ts b/packages/a2a-server/src/commands/memory.test.ts new file mode 100644 index 0000000000..40c5d1b90b --- /dev/null +++ b/packages/a2a-server/src/commands/memory.test.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + addMemory, + listMemoryFiles, + refreshMemory, + showMemory, +} from '@google/gemini-cli-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AddMemoryCommand, + ListMemoryCommand, + MemoryCommand, + RefreshMemoryCommand, + ShowMemoryCommand, +} from './memory.js'; +import type { CommandContext } from './types.js'; +import type { + AnyDeclarativeTool, + Config, + ToolRegistry, +} from '@google/gemini-cli-core'; + +// Mock the core functions +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + showMemory: vi.fn(), + refreshMemory: vi.fn(), + listMemoryFiles: vi.fn(), + addMemory: vi.fn(), + }; +}); + +const mockShowMemory = vi.mocked(showMemory); +const mockRefreshMemory = vi.mocked(refreshMemory); +const mockListMemoryFiles = vi.mocked(listMemoryFiles); +const mockAddMemory = vi.mocked(addMemory); + +describe('a2a-server memory commands', () => { + let mockContext: CommandContext; + let mockConfig: Config; + let mockToolRegistry: ToolRegistry; + let mockSaveMemoryTool: AnyDeclarativeTool; + + beforeEach(() => { + mockSaveMemoryTool = { + name: 'save_memory', + description: 'Saves memory', + buildAndExecute: vi.fn().mockResolvedValue(undefined), + } as unknown as AnyDeclarativeTool; + + mockToolRegistry = { + getTool: vi.fn(), + } as unknown as ToolRegistry; + + mockConfig = { + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + } as unknown as Config; + + mockContext = { + config: mockConfig, + }; + + vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockSaveMemoryTool); + }); + + describe('MemoryCommand', () => { + it('delegates to ShowMemoryCommand', async () => { + const command = new MemoryCommand(); + mockShowMemory.mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'showing memory', + }); + const response = await command.execute(mockContext, []); + expect(response.data).toBe('showing memory'); + expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config); + }); + }); + + describe('ShowMemoryCommand', () => { + it('executes showMemory and returns the content', async () => { + const command = new ShowMemoryCommand(); + mockShowMemory.mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'test memory content', + }); + + const response = await command.execute(mockContext, []); + + expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory show'); + expect(response.data).toBe('test memory content'); + }); + }); + + describe('RefreshMemoryCommand', () => { + it('executes refreshMemory and returns the content', async () => { + const command = new RefreshMemoryCommand(); + mockRefreshMemory.mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'memory refreshed', + }); + + const response = await command.execute(mockContext, []); + + expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory refresh'); + expect(response.data).toBe('memory refreshed'); + }); + }); + + describe('ListMemoryCommand', () => { + it('executes listMemoryFiles and returns the content', async () => { + const command = new ListMemoryCommand(); + mockListMemoryFiles.mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'file1.md\nfile2.md', + }); + + const response = await command.execute(mockContext, []); + + expect(mockListMemoryFiles).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory list'); + expect(response.data).toBe('file1.md\nfile2.md'); + }); + }); + + describe('AddMemoryCommand', () => { + it('returns message content if addMemory returns a message', async () => { + const command = new AddMemoryCommand(); + mockAddMemory.mockReturnValue({ + type: 'message', + messageType: 'error', + content: 'error message', + }); + + const response = await command.execute(mockContext, []); + + expect(mockAddMemory).toHaveBeenCalledWith(''); + expect(response.name).toBe('memory add'); + expect(response.data).toBe('error message'); + }); + + it('executes the save_memory tool if found', async () => { + const command = new AddMemoryCommand(); + const fact = 'this is a new fact'; + mockAddMemory.mockReturnValue({ + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact }, + }); + + const response = await command.execute(mockContext, [ + 'this', + 'is', + 'a', + 'new', + 'fact', + ]); + + expect(mockAddMemory).toHaveBeenCalledWith(fact); + expect(mockConfig.getToolRegistry).toHaveBeenCalled(); + expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory'); + expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith( + { fact }, + expect.any(AbortSignal), + undefined, + { + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }, + ); + expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory add'); + expect(response.data).toBe(`Added memory: "${fact}"`); + }); + + it('returns an error if the tool is not found', async () => { + const command = new AddMemoryCommand(); + const fact = 'another fact'; + mockAddMemory.mockReturnValue({ + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact }, + }); + vi.mocked(mockToolRegistry.getTool).mockReturnValue(undefined); + + const response = await command.execute(mockContext, ['another', 'fact']); + + expect(response.name).toBe('memory add'); + expect(response.data).toBe('Error: Tool save_memory not found.'); + }); + }); +}); diff --git a/packages/a2a-server/src/commands/memory.ts b/packages/a2a-server/src/commands/memory.ts new file mode 100644 index 0000000000..16af1d3fe2 --- /dev/null +++ b/packages/a2a-server/src/commands/memory.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + addMemory, + listMemoryFiles, + refreshMemory, + showMemory, +} from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +const DEFAULT_SANITIZATION_CONFIG = { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, +}; + +export class MemoryCommand implements Command { + readonly name = 'memory'; + readonly description = 'Manage memory.'; + readonly subCommands = [ + new ShowMemoryCommand(), + new RefreshMemoryCommand(), + new ListMemoryCommand(), + new AddMemoryCommand(), + ]; + readonly topLevel = true; + readonly requiresWorkspace = true; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + return new ShowMemoryCommand().execute(context, _); + } +} + +export class ShowMemoryCommand implements Command { + readonly name = 'memory show'; + readonly description = 'Shows the current memory contents.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = showMemory(context.config); + return { name: this.name, data: result.content }; + } +} + +export class RefreshMemoryCommand implements Command { + readonly name = 'memory refresh'; + readonly description = 'Refreshes the memory from the source.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = await refreshMemory(context.config); + return { name: this.name, data: result.content }; + } +} + +export class ListMemoryCommand implements Command { + readonly name = 'memory list'; + readonly description = 'Lists the paths of the GEMINI.md files in use.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = listMemoryFiles(context.config); + return { name: this.name, data: result.content }; + } +} + +export class AddMemoryCommand implements Command { + readonly name = 'memory add'; + readonly description = 'Add content to the memory.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const textToAdd = args.join(' ').trim(); + const result = addMemory(textToAdd); + if (result.type === 'message') { + return { name: this.name, data: result.content }; + } + + const toolRegistry = context.config.getToolRegistry(); + const tool = toolRegistry.getTool(result.toolName); + if (tool) { + const abortController = new AbortController(); + const signal = abortController.signal; + await tool.buildAndExecute(result.toolArgs, signal, undefined, { + sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + }); + await refreshMemory(context.config); + return { + name: this.name, + data: `Added memory: "${textToAdd}"`, + }; + } else { + return { + name: this.name, + data: `Error: Tool ${result.toolName} not found.`, + }; + } + } +} diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index a748c0b2d7..13d0d56995 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -37,6 +37,10 @@ export async function loadConfig( const workspaceDir = process.cwd(); const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS']; + const folderTrust = + settings.folderTrust === true || + process.env['GEMINI_FOLDER_TRUST'] === 'true'; + const configParams: ConfigParameters = { sessionId: taskId, model: settings.general?.previewFeatures @@ -72,7 +76,8 @@ export async function loadConfig( settings.fileFiltering?.enableRecursiveFileSearch, }, ideMode: false, - folderTrust: settings.folderTrust === true, + folderTrust, + trustedFolder: true, extensionLoader, checkpointing: process.env['CHECKPOINTING'] ? process.env['CHECKPOINTING'] === 'true' @@ -83,16 +88,18 @@ export async function loadConfig( }; const fileService = new FileDiscoveryService(workspaceDir); - const { memoryContent, fileCount } = await loadServerHierarchicalMemory( - workspaceDir, - [workspaceDir], - false, - fileService, - extensionLoader, - settings.folderTrust === true, - ); + const { memoryContent, fileCount, filePaths } = + await loadServerHierarchicalMemory( + workspaceDir, + [workspaceDir], + false, + fileService, + extensionLoader, + folderTrust, + ); configParams.userMemory = memoryContent; configParams.geminiMdFileCount = fileCount; + configParams.geminiMdFilePaths = filePaths; const config = new Config({ ...configParams, }); diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 178a133e93..63ebb5e36a 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -12,12 +12,11 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { - getErrorMessage, + refreshMemory, refreshServerHierarchicalMemory, SimpleExtensionLoader, type FileDiscoveryService, } from '@google/gemini-cli-core'; -import type { LoadServerHierarchicalMemoryResponse } from '@google/gemini-cli-core/index.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = @@ -28,10 +27,28 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { if (error instanceof Error) return error.message; return String(error); }), + refreshMemory: vi.fn(async (config) => { + if (config.isJitContextEnabled()) { + await config.getContextManager()?.refresh(); + const memoryContent = config.getUserMemory() || ''; + const fileCount = config.getGeminiMdFileCount() || 0; + return { + type: 'message', + messageType: 'info', + content: `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, + }; + } + return { + type: 'message', + messageType: 'info', + content: 'Memory refreshed successfully.', + }; + }), refreshServerHierarchicalMemory: vi.fn(), }; }); +const mockRefreshMemory = refreshMemory as Mock; const mockRefreshServerHierarchicalMemory = refreshServerHierarchicalMemory as Mock; @@ -208,7 +225,7 @@ describe('memoryCommand', () => { } as unknown as LoadedSettings, }, }); - mockRefreshServerHierarchicalMemory.mockClear(); + mockRefreshMemory.mockClear(); }); it('should use ContextManager.refresh when JIT is enabled', async () => { @@ -239,12 +256,13 @@ describe('memoryCommand', () => { it('should display success message when memory is refreshed with content (Legacy)', async () => { if (!refreshCommand.action) throw new Error('Command has no action'); - const refreshResult: LoadServerHierarchicalMemoryResponse = { - memoryContent: 'new memory content', - fileCount: 2, - filePaths: ['/path/one/GEMINI.md', '/path/two/GEMINI.md'], + const successMessage = { + type: 'message', + messageType: MessageType.INFO, + content: + 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', }; - mockRefreshServerHierarchicalMemory.mockResolvedValue(refreshResult); + mockRefreshMemory.mockResolvedValue(successMessage); await refreshCommand.action(mockContext, ''); @@ -256,7 +274,7 @@ describe('memoryCommand', () => { expect.any(Number), ); - expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce(); + expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { @@ -270,12 +288,16 @@ describe('memoryCommand', () => { it('should display success message when memory is refreshed with no content', async () => { if (!refreshCommand.action) throw new Error('Command has no action'); - const refreshResult = { memoryContent: '', fileCount: 0, filePaths: [] }; - mockRefreshServerHierarchicalMemory.mockResolvedValue(refreshResult); + const successMessage = { + type: 'message', + messageType: MessageType.INFO, + content: 'Memory refreshed successfully. No memory content found.', + }; + mockRefreshMemory.mockResolvedValue(successMessage); await refreshCommand.action(mockContext, ''); - expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce(); + expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { @@ -290,11 +312,11 @@ describe('memoryCommand', () => { if (!refreshCommand.action) throw new Error('Command has no action'); const error = new Error('Failed to read memory files.'); - mockRefreshServerHierarchicalMemory.mockRejectedValue(error); + mockRefreshMemory.mockRejectedValue(error); await refreshCommand.action(mockContext, ''); - expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce(); + expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockSetUserMemory).not.toHaveBeenCalled(); expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled(); expect(mockSetGeminiMdFilePaths).not.toHaveBeenCalled(); @@ -306,8 +328,6 @@ describe('memoryCommand', () => { }, expect.any(Number), ); - - expect(getErrorMessage).toHaveBeenCalledWith(error); }); it('should not throw if config service is unavailable', async () => { @@ -329,7 +349,7 @@ describe('memoryCommand', () => { expect.any(Number), ); - expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled(); + expect(mockRefreshMemory).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index d0df88f747..8f4bdaffbe 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -5,8 +5,10 @@ */ import { - getErrorMessage, - refreshServerHierarchicalMemory, + addMemory, + listMemoryFiles, + refreshMemory, + showMemory, } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; import type { SlashCommand, SlashCommandActionReturn } from './types.js'; @@ -24,18 +26,14 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const memoryContent = context.services.config?.getUserMemory() || ''; - const fileCount = context.services.config?.getGeminiMdFileCount() || 0; - - const messageContent = - memoryContent.length > 0 - ? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---` - : 'Memory is currently empty.'; + const config = context.services.config; + if (!config) return; + const result = showMemory(config); context.ui.addItem( { type: MessageType.INFO, - text: messageContent, + text: result.content, }, Date.now(), ); @@ -47,12 +45,10 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: false, action: (context, args): SlashCommandActionReturn | void => { - if (!args || args.trim() === '') { - return { - type: 'message', - messageType: 'error', - content: 'Usage: /memory add ', - }; + const result = addMemory(args); + + if (result.type === 'message') { + return result; } context.ui.addItem( @@ -63,11 +59,7 @@ export const memoryCommand: SlashCommand = { Date.now(), ); - return { - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact: args.trim() }, - }; + return result; }, }, { @@ -87,40 +79,21 @@ export const memoryCommand: SlashCommand = { try { const config = context.services.config; if (config) { - let memoryContent = ''; - let fileCount = 0; - - if (config.isJitContextEnabled()) { - await config.getContextManager()?.refresh(); - memoryContent = config.getUserMemory(); - fileCount = config.getGeminiMdFileCount(); - } else { - const result = await refreshServerHierarchicalMemory(config); - memoryContent = result.memoryContent; - fileCount = result.fileCount; - } - - await config.updateSystemInstructionIfInitialized(); - - const successMessage = - memoryContent.length > 0 - ? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).` - : 'Memory refreshed successfully. No memory content found.'; + const result = await refreshMemory(config); context.ui.addItem( { type: MessageType.INFO, - text: successMessage, + text: result.content, }, Date.now(), ); } } catch (error) { - const errorMessage = getErrorMessage(error); context.ui.addItem( { type: MessageType.ERROR, - text: `Error refreshing memory: ${errorMessage}`, + text: `Error refreshing memory: ${(error as Error).message}`, }, Date.now(), ); @@ -133,18 +106,14 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const filePaths = context.services.config?.getGeminiMdFilePaths() || []; - const fileCount = filePaths.length; - - const messageContent = - fileCount > 0 - ? `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}` - : 'No GEMINI.md files in use.'; + const config = context.services.config; + if (!config) return; + const result = listMemoryFiles(config); context.ui.addItem( { type: MessageType.INFO, - text: messageContent, + text: result.content, }, Date.now(), ); diff --git a/packages/core/src/commands/memory.test.ts b/packages/core/src/commands/memory.test.ts new file mode 100644 index 0000000000..3c885aa87c --- /dev/null +++ b/packages/core/src/commands/memory.test.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Config } from '../config/config.js'; +import { + addMemory, + listMemoryFiles, + refreshMemory, + showMemory, +} from './memory.js'; +import * as memoryDiscovery from '../utils/memoryDiscovery.js'; + +vi.mock('../utils/memoryDiscovery.js', () => ({ + refreshServerHierarchicalMemory: vi.fn(), +})); + +const mockRefresh = vi.mocked(memoryDiscovery.refreshServerHierarchicalMemory); + +describe('memory commands', () => { + let mockConfig: Config; + + beforeEach(() => { + mockConfig = { + getUserMemory: vi.fn(), + getGeminiMdFileCount: vi.fn(), + getGeminiMdFilePaths: vi.fn(), + isJitContextEnabled: vi.fn(), + updateSystemInstructionIfInitialized: vi + .fn() + .mockResolvedValue(undefined), + } as unknown as Config; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('showMemory', () => { + it('should show memory content if it exists', () => { + vi.mocked(mockConfig.getUserMemory).mockReturnValue( + 'some memory content', + ); + vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(1); + + const result = showMemory(mockConfig); + + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('info'); + expect(result.content).toContain( + 'Current memory content from 1 file(s)', + ); + expect(result.content).toContain('some memory content'); + } + }); + + it('should show a message if memory is empty', () => { + vi.mocked(mockConfig.getUserMemory).mockReturnValue(''); + vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(0); + + const result = showMemory(mockConfig); + + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('info'); + expect(result.content).toBe('Memory is currently empty.'); + } + }); + }); + + describe('addMemory', () => { + it('should return a tool action to save memory', () => { + const result = addMemory('new memory'); + expect(result.type).toBe('tool'); + if (result.type === 'tool') { + expect(result.toolName).toBe('save_memory'); + expect(result.toolArgs).toEqual({ fact: 'new memory' }); + } + }); + + it('should trim the arguments', () => { + const result = addMemory(' new memory '); + expect(result.type).toBe('tool'); + if (result.type === 'tool') { + expect(result.toolArgs).toEqual({ fact: 'new memory' }); + } + }); + + it('should return an error if args are empty', () => { + const result = addMemory(''); + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('error'); + expect(result.content).toBe('Usage: /memory add '); + } + }); + + it('should return an error if args are just whitespace', () => { + const result = addMemory(' '); + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('error'); + expect(result.content).toBe('Usage: /memory add '); + } + }); + + it('should return an error if args are undefined', () => { + const result = addMemory(undefined); + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('error'); + expect(result.content).toBe('Usage: /memory add '); + } + }); + }); + + describe('refreshMemory', () => { + it('should refresh memory and show success message', async () => { + mockRefresh.mockResolvedValue({ + memoryContent: 'refreshed content', + fileCount: 2, + filePaths: [], + }); + + const result = await refreshMemory(mockConfig); + + expect(mockRefresh).toHaveBeenCalledWith(mockConfig); + expect( + mockConfig.updateSystemInstructionIfInitialized, + ).toHaveBeenCalled(); + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('info'); + expect(result.content).toBe( + 'Memory refreshed successfully. Loaded 17 characters from 2 file(s).', + ); + } + }); + + it('should show a message if no memory content is found after refresh', async () => { + mockRefresh.mockResolvedValue({ + memoryContent: '', + fileCount: 0, + filePaths: [], + }); + + const result = await refreshMemory(mockConfig); + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('info'); + expect(result.content).toBe( + 'Memory refreshed successfully. No memory content found.', + ); + } + }); + }); + + describe('listMemoryFiles', () => { + it('should list the memory files in use', () => { + const filePaths = ['/path/to/GEMINI.md', '/other/path/GEMINI.md']; + vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue(filePaths); + + const result = listMemoryFiles(mockConfig); + + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('info'); + expect(result.content).toContain( + 'There are 2 GEMINI.md file(s) in use:', + ); + expect(result.content).toContain(filePaths.join('\n')); + } + }); + + it('should show a message if no memory files are in use', () => { + vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue([]); + + const result = listMemoryFiles(mockConfig); + + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('info'); + expect(result.content).toBe('No GEMINI.md files in use.'); + } + }); + + it('should show a message if file paths are undefined', () => { + vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue( + undefined as unknown as string[], + ); + + const result = listMemoryFiles(mockConfig); + + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.messageType).toBe('info'); + expect(result.content).toBe('No GEMINI.md files in use.'); + } + }); + }); +}); diff --git a/packages/core/src/commands/memory.ts b/packages/core/src/commands/memory.ts new file mode 100644 index 0000000000..6065cf0dab --- /dev/null +++ b/packages/core/src/commands/memory.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js'; +import type { MessageActionReturn, ToolActionReturn } from './types.js'; + +export function showMemory(config: Config): MessageActionReturn { + const memoryContent = config.getUserMemory() || ''; + const fileCount = config.getGeminiMdFileCount() || 0; + let content: string; + + if (memoryContent.length > 0) { + content = `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`; + } else { + content = 'Memory is currently empty.'; + } + + return { + type: 'message', + messageType: 'info', + content, + }; +} + +export function addMemory( + args?: string, +): MessageActionReturn | ToolActionReturn { + if (!args || args.trim() === '') { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /memory add ', + }; + } + return { + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact: args.trim() }, + }; +} + +export async function refreshMemory( + config: Config, +): Promise { + let memoryContent = ''; + let fileCount = 0; + + if (config.isJitContextEnabled()) { + await config.getContextManager()?.refresh(); + memoryContent = config.getUserMemory(); + fileCount = config.getGeminiMdFileCount(); + } else { + const result = await refreshServerHierarchicalMemory(config); + memoryContent = result.memoryContent; + fileCount = result.fileCount; + } + + await config.updateSystemInstructionIfInitialized(); + let content: string; + + if (memoryContent.length > 0) { + content = `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`; + } else { + content = 'Memory refreshed successfully. No memory content found.'; + } + + return { + type: 'message', + messageType: 'info', + content, + }; +} + +export function listMemoryFiles(config: Config): MessageActionReturn { + const filePaths = config.getGeminiMdFilePaths() || []; + const fileCount = filePaths.length; + let content: string; + + if (fileCount > 0) { + content = `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join( + '\n', + )}`; + } else { + content = 'No GEMINI.md files in use.'; + } + + return { + type: 'message', + messageType: 'info', + content, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ce62f9fcfa..d587d3f221 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,6 +22,7 @@ export * from './confirmation-bus/message-bus.js'; export * from './commands/extensions.js'; export * from './commands/restore.js'; export * from './commands/init.js'; +export * from './commands/memory.js'; export * from './commands/types.js'; // Export Core Logic