From 1bdec55fe1c658069a45df0aa8e4923ba1954e41 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Fri, 16 May 2025 16:36:50 -0700 Subject: [PATCH] feat: Implement CLI and model memory management (#371) Co-authored-by: N. Taylor Mullen --- packages/cli/src/config/config.ts | 4 +- packages/cli/src/config/memoryUtils.ts | 163 ++++++++++++ .../ui/hooks/slashCommandProcessor.test.ts | 248 ++++++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 132 +++++++--- packages/server/src/config/config.ts | 4 +- .../core/__snapshots__/prompts.test.ts.snap | 7 + packages/server/src/core/prompts.ts | 2 + packages/server/src/index.ts | 1 + packages/server/src/tools/memoryTool.test.ts | 224 ++++++++++++++++ packages/server/src/tools/memoryTool.ts | 194 ++++++++++++++ packages/server/src/tools/read-many-files.ts | 3 +- 11 files changed, 940 insertions(+), 42 deletions(-) create mode 100644 packages/cli/src/config/memoryUtils.ts create mode 100644 packages/cli/src/ui/hooks/slashCommandProcessor.test.ts create mode 100644 packages/server/src/tools/memoryTool.test.ts create mode 100644 packages/server/src/tools/memoryTool.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c0ec38af..9f03bee4 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -15,6 +15,8 @@ import { Config, loadEnvironment, createServerConfig, + GEMINI_CONFIG_DIR, + GEMINI_MD_FILENAME, } from '@gemini-code/server'; import { Settings } from './settings.js'; import { readPackageUp } from 'read-package-up'; @@ -30,8 +32,6 @@ const logger = { }; const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro-preview-05-06'; -const GEMINI_MD_FILENAME = 'GEMINI.md'; -const GEMINI_CONFIG_DIR = '.gemini'; // TODO(adh): Refactor to use a shared ignore list with other tools like glob and read-many-files. const DEFAULT_IGNORE_DIRECTORIES = [ 'node_modules', diff --git a/packages/cli/src/config/memoryUtils.ts b/packages/cli/src/config/memoryUtils.ts new file mode 100644 index 00000000..d44b5ebf --- /dev/null +++ b/packages/cli/src/config/memoryUtils.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { homedir } from 'os'; +import { SETTINGS_DIRECTORY_NAME } from './settings.js'; +import { + getErrorMessage, + MemoryTool, + GEMINI_MD_FILENAME, + MEMORY_SECTION_HEADER, +} from '@gemini-code/server'; + +/** + * Gets the absolute path to the global GEMINI.md file. + */ +export function getGlobalMemoryFilePath(): string { + return path.join(homedir(), SETTINGS_DIRECTORY_NAME, GEMINI_MD_FILENAME); +} + +/** + * Adds a new memory entry to the global GEMINI.md file under the specified header. + */ +export async function addMemoryEntry(text: string): Promise { + const filePath = getGlobalMemoryFilePath(); + // The performAddMemoryEntry method from MemoryTool will handle its own errors + // and throw an appropriately formatted error if needed. + await MemoryTool.performAddMemoryEntry(text, filePath, { + readFile: fs.readFile, + writeFile: fs.writeFile, + mkdir: fs.mkdir, + }); +} + +/** + * Deletes the last added memory entry from the "Gemini Added Memories" section. + */ +export async function deleteLastMemoryEntry(): Promise { + const filePath = getGlobalMemoryFilePath(); + try { + let content = await fs.readFile(filePath, 'utf-8'); + const headerIndex = content.indexOf(MEMORY_SECTION_HEADER); + + if (headerIndex === -1) return false; // Section not found + + const startOfSectionContent = headerIndex + MEMORY_SECTION_HEADER.length; + let endOfSectionIndex = content.indexOf('\n## ', startOfSectionContent); + if (endOfSectionIndex === -1) { + endOfSectionIndex = content.length; + } + + const sectionPart = content.substring( + startOfSectionContent, + endOfSectionIndex, + ); + const lines = sectionPart.split(/\r?\n/).map((line) => line.trimEnd()); + + let lastBulletLineIndex = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim().startsWith('- ')) { + lastBulletLineIndex = i; + break; + } + } + + if (lastBulletLineIndex === -1) return false; // No bullets found in section + + lines.splice(lastBulletLineIndex, 1); + + const newSectionPart = lines + .filter((line) => line.trim().length > 0) + .join('\n'); + + const beforeHeader = content.substring(0, headerIndex); + const afterSection = content.substring(endOfSectionIndex); + + if (newSectionPart.trim().length === 0) { + // If section is now empty (no bullets), remove header too or leave it clean + // For simplicity, let's leave the header but ensure it has a newline after if content follows + content = `${beforeHeader}${MEMORY_SECTION_HEADER}\n${afterSection}` + .replace(/\n{3,}/g, '\n\n') + .trimEnd(); + if (content.length > 0) content += '\n'; + } else { + content = + `${beforeHeader}${MEMORY_SECTION_HEADER}\n${newSectionPart}\n${afterSection}` + .replace(/\n{3,}/g, '\n\n') + .trimEnd(); + if (content.length > 0) content += '\n'; + } + + await fs.writeFile(filePath, content, 'utf-8'); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false; + } + console.error(`Error deleting last memory entry from ${filePath}:`, error); + throw new Error( + `Failed to delete last memory entry: ${getErrorMessage(error)}`, + ); + } +} + +/** + * Deletes all added memory entries (the entire "Gemini Added Memories" section). + */ +export async function deleteAllAddedMemoryEntries(): Promise { + const filePath = getGlobalMemoryFilePath(); + try { + let content = await fs.readFile(filePath, 'utf-8'); + const headerIndex = content.indexOf(MEMORY_SECTION_HEADER); + + if (headerIndex === -1) return 0; // Section not found + + let endOfSectionIndex = content.indexOf( + '\n## ', + headerIndex + MEMORY_SECTION_HEADER.length, + ); + if (endOfSectionIndex === -1) { + endOfSectionIndex = content.length; // Section goes to EOF + } + + const sectionContent = content.substring(headerIndex, endOfSectionIndex); + const bulletCount = (sectionContent.match(/\n- /g) || []).length; + + if (bulletCount === 0 && !sectionContent.includes('- ')) { + // No bullets found + // If we only remove if bullets exist, or remove header if no bullets. + // For now, if header exists but no bullets, consider 0 deleted if we only count bullets. + // If the goal is to remove the section if it exists, this logic changes. + // Let's assume we only care about bulleted items for the count. + } + + // Remove the section including the header + const beforeHeader = content.substring(0, headerIndex); + const afterSection = content.substring(endOfSectionIndex); + + content = ( + beforeHeader.trimEnd() + + (afterSection.length > 0 ? '\n' + afterSection.trimStart() : '') + ).trim(); + if (content.length > 0) content += '\n'; + + await fs.writeFile(filePath, content, 'utf-8'); + return bulletCount; // This counts '\n- ' occurrences, might need refinement for exact bullet count + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return 0; + } + console.error( + `Error deleting all added memory entries from ${filePath}:`, + error, + ); + throw new Error( + `Failed to delete all added memory entries: ${getErrorMessage(error)}`, + ); + } +} diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts new file mode 100644 index 00000000..4055dfd3 --- /dev/null +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const { mockProcessExit } = vi.hoisted(() => ({ + mockProcessExit: vi.fn((_code?: number): never => undefined as never), +})); + +vi.mock('node:process', () => ({ + exit: mockProcessExit, + cwd: vi.fn(() => '/mock/cwd'), +})); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), +})); + +import { act, renderHook } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; +import { useSlashCommandProcessor } from './slashCommandProcessor.js'; +import { MessageType } from '../types.js'; +import * as memoryUtils from '../../config/memoryUtils.js'; +import { type Config, MemoryTool } from '@gemini-code/server'; +import * as fsPromises from 'node:fs/promises'; + +// Import the module for mocking its functions +import * as ShowMemoryCommandModule from './useShowMemoryCommand.js'; + +// Mock dependencies +vi.mock('./useShowMemoryCommand.js', () => ({ + SHOW_MEMORY_COMMAND_NAME: '/memory show', + createShowMemoryAction: vi.fn(() => vi.fn()), +})); + +// Spy on the static method we want to mock +const performAddMemoryEntrySpy = vi.spyOn(MemoryTool, 'performAddMemoryEntry'); + +describe('useSlashCommandProcessor', () => { + let mockAddItem: ReturnType; + let mockClearItems: ReturnType; + let mockRefreshStatic: ReturnType; + let mockSetShowHelp: ReturnType; + let mockOnDebugMessage: ReturnType; + let mockOpenThemeDialog: ReturnType; + let mockPerformMemoryRefresh: ReturnType; + let mockConfig: Config; + + beforeEach(() => { + mockAddItem = vi.fn(); + mockClearItems = vi.fn(); + mockRefreshStatic = vi.fn(); + mockSetShowHelp = vi.fn(); + mockOnDebugMessage = vi.fn(); + mockOpenThemeDialog = vi.fn(); + mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined); + mockConfig = { getDebugMode: vi.fn(() => false) } as unknown as Config; + + // Clear mocks for fsPromises if they were used directly or indirectly + vi.mocked(fsPromises.readFile).mockClear(); + vi.mocked(fsPromises.writeFile).mockClear(); + vi.mocked(fsPromises.mkdir).mockClear(); + + performAddMemoryEntrySpy.mockReset(); // Reset the spy + vi.spyOn(memoryUtils, 'deleteLastMemoryEntry').mockImplementation(vi.fn()); + vi.spyOn(memoryUtils, 'deleteAllAddedMemoryEntries').mockImplementation( + vi.fn(), + ); + + vi.mocked(memoryUtils.deleteLastMemoryEntry).mockClear(); + vi.mocked(memoryUtils.deleteAllAddedMemoryEntries).mockClear(); + + mockProcessExit.mockClear(); + (ShowMemoryCommandModule.createShowMemoryAction as Mock).mockClear(); + mockPerformMemoryRefresh.mockClear(); + }); + + const getProcessor = () => { + const { result } = renderHook(() => + useSlashCommandProcessor( + mockConfig, + mockAddItem, + mockClearItems, + mockRefreshStatic, + mockSetShowHelp, + mockOnDebugMessage, + mockOpenThemeDialog, + mockPerformMemoryRefresh, + ), + ); + return result.current; + }; + + describe('/memory add', () => { + it('should call MemoryTool.performAddMemoryEntry and refresh on valid input', async () => { + performAddMemoryEntrySpy.mockResolvedValue(undefined); + const { handleSlashCommand } = getProcessor(); + const fact = 'Remember this fact'; + await act(async () => { + handleSlashCommand(`/memory add ${fact}`); + }); + expect(mockAddItem).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: MessageType.USER, + text: `/memory add ${fact}`, + }), + expect.any(Number), + ); + expect(performAddMemoryEntrySpy).toHaveBeenCalledWith( + fact, + memoryUtils.getGlobalMemoryFilePath(), // Ensure this path is correct + { + readFile: fsPromises.readFile, + writeFile: fsPromises.writeFile, + mkdir: fsPromises.mkdir, + }, + ); + expect(mockPerformMemoryRefresh).toHaveBeenCalled(); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added to memory: "${fact}"`, + }), + expect.any(Number), + ); + }); + + it('should show usage error if no text is provided', async () => { + const { handleSlashCommand } = getProcessor(); + await act(async () => { + handleSlashCommand('/memory add '); + }); + expect(performAddMemoryEntrySpy).not.toHaveBeenCalled(); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Usage: /memory add ', + }), + expect.any(Number), + ); + }); + + it('should handle error from MemoryTool.performAddMemoryEntry', async () => { + const fact = 'Another fact'; + performAddMemoryEntrySpy.mockRejectedValue( + new Error('[MemoryTool] Failed to add memory entry: Disk full'), + ); + const { handleSlashCommand } = getProcessor(); + await act(async () => { + handleSlashCommand(`/memory add ${fact}`); + }); + expect(performAddMemoryEntrySpy).toHaveBeenCalledWith( + fact, + memoryUtils.getGlobalMemoryFilePath(), + { + readFile: fsPromises.readFile, + writeFile: fsPromises.writeFile, + mkdir: fsPromises.mkdir, + }, + ); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to add memory: [MemoryTool] Failed to add memory entry: Disk full', + }), + expect.any(Number), + ); + }); + }); + + describe('/memory show', () => { + it('should call the showMemoryAction', async () => { + const mockReturnedShowAction = vi.fn(); + vi.mocked(ShowMemoryCommandModule.createShowMemoryAction).mockReturnValue( + mockReturnedShowAction, + ); + const { handleSlashCommand } = getProcessor(); + await act(async () => { + handleSlashCommand('/memory show'); + }); + expect( + ShowMemoryCommandModule.createShowMemoryAction, + ).toHaveBeenCalledWith(mockConfig, expect.any(Function)); + expect(mockReturnedShowAction).toHaveBeenCalled(); + }); + }); + + describe('/memory refresh', () => { + it('should call performMemoryRefresh', async () => { + const { handleSlashCommand } = getProcessor(); + await act(async () => { + handleSlashCommand('/memory refresh'); + }); + expect(mockPerformMemoryRefresh).toHaveBeenCalled(); + }); + }); + + describe('Unknown /memory subcommand', () => { + it('should show an error for unknown /memory subcommand', async () => { + const { handleSlashCommand } = getProcessor(); + await act(async () => { + handleSlashCommand('/memory foobar'); + }); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Unknown /memory command: foobar. Available: show, refresh, add', + }), + expect.any(Number), + ); + }); + }); + + describe('Other commands', () => { + it('/help should open help', async () => { + const { handleSlashCommand } = getProcessor(); + await act(async () => { + handleSlashCommand('/help'); + }); + expect(mockSetShowHelp).toHaveBeenCalledWith(true); + }); + }); + + describe('Unknown command', () => { + it('should show an error for a general unknown command', async () => { + const { handleSlashCommand } = getProcessor(); + await act(async () => { + handleSlashCommand('/unknowncommand'); + }); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Unknown command: /unknowncommand', + }), + expect.any(Number), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index efe6d855..f489c648 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -6,22 +6,17 @@ import { useCallback, useMemo } from 'react'; import { type PartListUnion } from '@google/genai'; -import { getCommandFromQuery } from '../utils/commandUtils.js'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; -import { Config } from '@gemini-code/server'; // Import Config -import { Message, MessageType, HistoryItemWithoutId } from '../types.js'; // Import Message types -import { - createShowMemoryAction, - SHOW_MEMORY_COMMAND_NAME, -} from './useShowMemoryCommand.js'; -import { REFRESH_MEMORY_COMMAND_NAME } from './useRefreshMemoryCommand.js'; // Only import name now -import process from 'node:process'; // For process.exit +import { Config } from '@gemini-code/server'; +import { Message, MessageType, HistoryItemWithoutId } from '../types.js'; +import { createShowMemoryAction } from './useShowMemoryCommand.js'; +import { addMemoryEntry } from '../../config/memoryUtils.js'; export interface SlashCommand { name: string; altName?: string; description: string; - action: (value: PartListUnion | string) => void; // Allow string for simpler actions + action: (mainCommand: string, subCommand?: string, args?: string) => void; } /** @@ -54,13 +49,43 @@ export const useSlashCommandProcessor = ( await actionFn(); }, [config, addMessage]); + const addMemoryAction = useCallback( + async (_mainCommand: string, _subCommand?: string, args?: string) => { + if (!args || args.trim() === '') { + addMessage({ + type: MessageType.ERROR, + content: 'Usage: /memory add ', + timestamp: new Date(), + }); + return; + } + try { + await addMemoryEntry(args); + addMessage({ + type: MessageType.INFO, + content: `Successfully added to memory: "${args}"`, + timestamp: new Date(), + }); + await performMemoryRefresh(); // Refresh memory to reflect changes + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + addMessage({ + type: MessageType.ERROR, + content: `Failed to add memory: ${errorMessage}`, + timestamp: new Date(), + }); + } + }, + [addMessage, performMemoryRefresh], + ); + const slashCommands: SlashCommand[] = useMemo( () => [ { name: 'help', altName: '?', description: 'for help on gemini-code', - action: (_value: PartListUnion | string) => { + action: (_mainCommand, _subCommand, _args) => { onDebugMessage('Opening help.'); setShowHelp(true); }, @@ -68,7 +93,7 @@ export const useSlashCommandProcessor = ( { name: 'clear', description: 'clear the screen', - action: (_value: PartListUnion | string) => { + action: (_mainCommand, _subCommand, _args) => { onDebugMessage('Clearing terminal.'); clearItems(); console.clear(); @@ -78,25 +103,39 @@ export const useSlashCommandProcessor = ( { name: 'theme', description: 'change the theme', - action: (_value) => { + action: (_mainCommand, _subCommand, _args) => { openThemeDialog(); }, }, { - name: REFRESH_MEMORY_COMMAND_NAME.substring(1), // Remove leading '/' - description: 'Reloads instructions from all GEMINI.md files.', - action: performMemoryRefresh, // Use the passed in function - }, - { - name: SHOW_MEMORY_COMMAND_NAME.substring(1), // Remove leading '/' - description: 'Displays the current hierarchical memory content.', - action: showMemoryAction, + name: 'memory', + description: + 'Manage memory. Usage: /memory [text for add]', + action: (mainCommand, subCommand, args) => { + switch (subCommand) { + case 'show': + showMemoryAction(); + break; + case 'refresh': + performMemoryRefresh(); + break; + case 'add': + addMemoryAction(mainCommand, subCommand, args); + break; + default: + addMessage({ + type: MessageType.ERROR, + content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`, + timestamp: new Date(), + }); + } + }, }, { name: 'quit', altName: 'exit', description: 'exit the cli', - action: (_value: PartListUnion | string) => { + action: (_mainCommand, _subCommand, _args) => { onDebugMessage('Quitting. Good-bye.'); process.exit(0); }, @@ -108,8 +147,10 @@ export const useSlashCommandProcessor = ( refreshStatic, openThemeDialog, clearItems, - performMemoryRefresh, // Add to dependencies + performMemoryRefresh, showMemoryAction, + addMemoryAction, + addMessage, ], ); @@ -120,36 +161,51 @@ export const useSlashCommandProcessor = ( } const trimmed = rawQuery.trim(); - const [symbol, test] = getCommandFromQuery(trimmed); - if (symbol !== '/' && symbol !== '?') { + if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { return false; } const userMessageTimestamp = Date.now(); - // Add user message to history only if it's not a silent command or handled internally - // For now, adding all slash commands to history for transparency. + addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp); + let subCommand: string | undefined; + let args: string | undefined; + + const commandToMatch = (() => { + if (trimmed.startsWith('?')) { + return 'help'; // No subCommand or args for '?' acting as help + } + // For other slash commands like /memory add foo + const parts = trimmed.substring(1).trim().split(/\s+/); + if (parts.length > 1) { + subCommand = parts[1]; + } + if (parts.length > 2) { + args = parts.slice(2).join(' '); + } + return parts[0]; // This is the main command name + })(); + + const mainCommand = commandToMatch; + for (const cmd of slashCommands) { - if ( - test === cmd.name || - test === cmd.altName || - (symbol === '?' && cmd.altName === '?') // Special handling for ? as help - ) { - cmd.action(trimmed); // Pass the full trimmed command for context if needed + if (mainCommand === cmd.name || mainCommand === cmd.altName) { + cmd.action(mainCommand, subCommand, args); return true; } } - addItem( - { type: MessageType.ERROR, text: `Unknown command: ${trimmed}` }, - userMessageTimestamp, - ); + addMessage({ + type: MessageType.ERROR, + content: `Unknown command: ${trimmed}`, + timestamp: new Date(), + }); return true; }, - [addItem, slashCommands], + [addItem, slashCommands, addMessage], ); return { handleSlashCommand, slashCommands }; diff --git a/packages/server/src/config/config.ts b/packages/server/src/config/config.ts index 4221b71e..8b9648c4 100644 --- a/packages/server/src/config/config.ts +++ b/packages/server/src/config/config.ts @@ -20,6 +20,7 @@ import { WriteFileTool } from '../tools/write-file.js'; import { WebFetchTool } from '../tools/web-fetch.js'; import { ReadManyFilesTool } from '../tools/read-many-files.js'; import { BaseTool, ToolResult } from '../tools/tools.js'; +import { MemoryTool } from '../tools/memoryTool.js'; import { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js'; export class Config { @@ -188,9 +189,10 @@ function createToolRegistry(config: Config): ToolRegistry { new GlobTool(targetDir), new EditTool(config), new WriteFileTool(targetDir), - new WebFetchTool(), // Note: WebFetchTool takes no arguments + new WebFetchTool(), new ReadManyFilesTool(targetDir), new ShellTool(config), + new MemoryTool(), ]; for (const tool of tools) { diff --git a/packages/server/src/core/__snapshots__/prompts.test.ts.snap b/packages/server/src/core/__snapshots__/prompts.test.ts.snap index 8305ee00..b824d4f7 100644 --- a/packages/server/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/server/src/core/__snapshots__/prompts.test.ts.snap @@ -65,6 +65,7 @@ Rigorously adhere to existing project conventions when reading or modifying code - **Command Execution:** Use the 'execute_bash_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user. +- **Remembering Facts:** Use the 'saveMemory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -218,6 +219,7 @@ Rigorously adhere to existing project conventions when reading or modifying code - **Command Execution:** Use the 'execute_bash_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user. +- **Remembering Facts:** Use the 'saveMemory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -366,6 +368,7 @@ Rigorously adhere to existing project conventions when reading or modifying code - **Command Execution:** Use the 'execute_bash_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user. +- **Remembering Facts:** Use the 'saveMemory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -514,6 +517,7 @@ Rigorously adhere to existing project conventions when reading or modifying code - **Command Execution:** Use the 'execute_bash_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user. +- **Remembering Facts:** Use the 'saveMemory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -662,6 +666,7 @@ Rigorously adhere to existing project conventions when reading or modifying code - **Command Execution:** Use the 'execute_bash_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user. +- **Remembering Facts:** Use the 'saveMemory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -810,6 +815,7 @@ Rigorously adhere to existing project conventions when reading or modifying code - **Command Execution:** Use the 'execute_bash_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user. +- **Remembering Facts:** Use the 'saveMemory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" ## Interaction Details - **Help Command:** The user can use '/help' to display help information. @@ -958,6 +964,7 @@ Rigorously adhere to existing project conventions when reading or modifying code - **Command Execution:** Use the 'execute_bash_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user. +- **Remembering Facts:** Use the 'saveMemory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" ## Interaction Details - **Help Command:** The user can use '/help' to display help information. diff --git a/packages/server/src/core/prompts.ts b/packages/server/src/core/prompts.ts index 40a56a2a..d21c78fe 100644 --- a/packages/server/src/core/prompts.ts +++ b/packages/server/src/core/prompts.ts @@ -14,6 +14,7 @@ import { ShellTool } from '../tools/shell.js'; import { WriteFileTool } from '../tools/write-file.js'; import process from 'node:process'; // Import process import { execSync } from 'node:child_process'; +import { MemoryTool } from '../tools/memoryTool.js'; const contactEmail = 'gemini-code-dev@google.com'; @@ -82,6 +83,7 @@ Rigorously adhere to existing project conventions when reading or modifying code - **Command Execution:** Use the '${ShellTool.Name}' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user. +- **Remembering Facts:** Use the '${MemoryTool.Name}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" ## Interaction Details - **Help Command:** The user can use '/help' to display help information. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a1ebc571..9183c2f9 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -32,3 +32,4 @@ export * from './tools/glob.js'; export * from './tools/edit.js'; export * from './tools/write-file.js'; export * from './tools/web-fetch.js'; +export * from './tools/memoryTool.js'; diff --git a/packages/server/src/tools/memoryTool.test.ts b/packages/server/src/tools/memoryTool.test.ts new file mode 100644 index 00000000..efbbb025 --- /dev/null +++ b/packages/server/src/tools/memoryTool.test.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest'; +import { MemoryTool } from './memoryTool.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +// Mock dependencies +vi.mock('fs/promises'); +vi.mock('os'); + +const MEMORY_SECTION_HEADER = '## Gemini Added Memories'; + +// Define a type for our fsAdapter to ensure consistency +interface FsAdapter { + readFile: (path: string, encoding: 'utf-8') => Promise; + writeFile: (path: string, data: string, encoding: 'utf-8') => Promise; + mkdir: ( + path: string, + options: { recursive: boolean }, + ) => Promise; +} + +describe('MemoryTool', () => { + const mockAbortSignal = new AbortController().signal; + + const mockFsAdapter: { + readFile: Mock; + writeFile: Mock; + mkdir: Mock; + } = { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }; + + beforeEach(() => { + vi.mocked(os.homedir).mockReturnValue('/mock/home'); + mockFsAdapter.readFile.mockReset(); + mockFsAdapter.writeFile.mockReset().mockResolvedValue(undefined); + mockFsAdapter.mkdir + .mockReset() + .mockResolvedValue(undefined as string | undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('performAddMemoryEntry (static method)', () => { + const testFilePath = path.join('/mock/home', '.gemini', 'GEMINI.md'); + + it('should create section and save a fact if file does not exist', async () => { + mockFsAdapter.readFile.mockRejectedValue({ code: 'ENOENT' }); // Simulate file not found + const fact = 'The sky is blue'; + await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); + + expect(mockFsAdapter.mkdir).toHaveBeenCalledWith( + path.dirname(testFilePath), + { + recursive: true, + }, + ); + expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); + const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; + expect(writeFileCall[0]).toBe(testFilePath); + const expectedContent = `${MEMORY_SECTION_HEADER}\n- ${fact}\n`; + expect(writeFileCall[1]).toBe(expectedContent); + expect(writeFileCall[2]).toBe('utf-8'); + }); + + it('should create section and save a fact if file is empty', async () => { + mockFsAdapter.readFile.mockResolvedValue(''); // Simulate empty file + const fact = 'The sky is blue'; + await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); + const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; + const expectedContent = `${MEMORY_SECTION_HEADER}\n- ${fact}\n`; + expect(writeFileCall[1]).toBe(expectedContent); + }); + + it('should add a fact to an existing section', async () => { + const initialContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- Existing fact 1\n`; + mockFsAdapter.readFile.mockResolvedValue(initialContent); + const fact = 'New fact 2'; + await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); + + expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); + const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; + const expectedContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- Existing fact 1\n- ${fact}\n`; + expect(writeFileCall[1]).toBe(expectedContent); + }); + + it('should add a fact to an existing empty section', async () => { + const initialContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n`; // Empty section + mockFsAdapter.readFile.mockResolvedValue(initialContent); + const fact = 'First fact in section'; + await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); + + expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); + const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; + const expectedContent = `Some preamble.\n\n${MEMORY_SECTION_HEADER}\n- ${fact}\n`; + expect(writeFileCall[1]).toBe(expectedContent); + }); + + it('should add a fact when other ## sections exist and preserve spacing', async () => { + const initialContent = `${MEMORY_SECTION_HEADER}\n- Fact 1\n\n## Another Section\nSome other text.`; + mockFsAdapter.readFile.mockResolvedValue(initialContent); + const fact = 'Fact 2'; + await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); + + expect(mockFsAdapter.writeFile).toHaveBeenCalledOnce(); + const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; + // Note: The implementation ensures a single newline at the end if content exists. + const expectedContent = `${MEMORY_SECTION_HEADER}\n- Fact 1\n- ${fact}\n\n## Another Section\nSome other text.\n`; + expect(writeFileCall[1]).toBe(expectedContent); + }); + + it('should correctly trim and add a fact that starts with a dash', async () => { + mockFsAdapter.readFile.mockResolvedValue(`${MEMORY_SECTION_HEADER}\n`); + const fact = '- - My fact with dashes'; + await MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter); + const writeFileCall = mockFsAdapter.writeFile.mock.calls[0]; + const expectedContent = `${MEMORY_SECTION_HEADER}\n- My fact with dashes\n`; + expect(writeFileCall[1]).toBe(expectedContent); + }); + + it('should handle error from fsAdapter.writeFile', async () => { + mockFsAdapter.readFile.mockResolvedValue(''); + mockFsAdapter.writeFile.mockRejectedValue(new Error('Disk full')); + const fact = 'This will fail'; + await expect( + MemoryTool.performAddMemoryEntry(fact, testFilePath, mockFsAdapter), + ).rejects.toThrow('[MemoryTool] Failed to add memory entry: Disk full'); + }); + }); + + describe('execute (instance method)', () => { + let memoryTool: MemoryTool; + let performAddMemoryEntrySpy: Mock; + + beforeEach(() => { + memoryTool = new MemoryTool(); + // Spy on the static method for these tests + performAddMemoryEntrySpy = vi + .spyOn(MemoryTool, 'performAddMemoryEntry') + .mockResolvedValue(undefined) as Mock< + typeof MemoryTool.performAddMemoryEntry + >; + // Cast needed as spyOn returns MockInstance + }); + + it('should have correct name, displayName, description, and schema', () => { + expect(memoryTool.name).toBe('saveMemory'); + expect(memoryTool.displayName).toBe('Save Memory'); + expect(memoryTool.description).toContain( + 'Saves a specific piece of information', + ); + expect(memoryTool.schema).toBeDefined(); + expect(memoryTool.schema.name).toBe('saveMemory'); + expect(memoryTool.schema.parameters?.properties?.fact).toBeDefined(); + }); + + it('should call performAddMemoryEntry with correct parameters and return success', async () => { + const params = { fact: 'The sky is blue' }; + const result = await memoryTool.execute(params, mockAbortSignal); + const expectedFilePath = path.join('/mock/home', '.gemini', 'GEMINI.md'); + + // For this test, we expect the actual fs methods to be passed + const expectedFsArgument = { + readFile: fs.readFile, + writeFile: fs.writeFile, + mkdir: fs.mkdir, + }; + + expect(performAddMemoryEntrySpy).toHaveBeenCalledWith( + params.fact, + expectedFilePath, + expectedFsArgument, + ); + const successMessage = `Okay, I've remembered that: "${params.fact}"`; + expect(result.llmContent).toBe( + JSON.stringify({ success: true, message: successMessage }), + ); + expect(result.returnDisplay).toBe(successMessage); + }); + + it('should return an error if fact is empty', async () => { + const params = { fact: ' ' }; // Empty fact + const result = await memoryTool.execute(params, mockAbortSignal); + const errorMessage = 'Parameter "fact" must be a non-empty string.'; + + expect(performAddMemoryEntrySpy).not.toHaveBeenCalled(); + expect(result.llmContent).toBe( + JSON.stringify({ success: false, error: errorMessage }), + ); + expect(result.returnDisplay).toBe(`Error: ${errorMessage}`); + }); + + it('should handle errors from performAddMemoryEntry', async () => { + const params = { fact: 'This will fail' }; + const underlyingError = new Error( + '[MemoryTool] Failed to add memory entry: Disk full', + ); + performAddMemoryEntrySpy.mockRejectedValue(underlyingError); + + const result = await memoryTool.execute(params, mockAbortSignal); + + expect(result.llmContent).toBe( + JSON.stringify({ + success: false, + error: `Failed to save memory. Detail: ${underlyingError.message}`, + }), + ); + expect(result.returnDisplay).toBe( + `Error saving memory: ${underlyingError.message}`, + ); + }); + }); +}); diff --git a/packages/server/src/tools/memoryTool.ts b/packages/server/src/tools/memoryTool.ts new file mode 100644 index 00000000..177072fe --- /dev/null +++ b/packages/server/src/tools/memoryTool.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseTool, ToolResult } from './tools.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { homedir } from 'os'; + +const memoryToolSchemaData = { + name: 'saveMemory', + description: + 'Saves a specific piece of information or fact to your long-term memory. Use this when the user explicitly asks you to remember something, or when they state a clear, concise fact that seems important to retain for future interactions.', + parameters: { + type: 'object', + properties: { + fact: { + type: 'string', + description: + 'The specific fact or piece of information to remember. Should be a clear, self-contained statement.', + }, + }, + required: ['fact'], + }, +}; + +const memoryToolDescription = ` +Saves a specific piece of information or fact to your long-term memory. + +Use this tool: + +- When the user explicitly asks you to remember something (e.g., "Remember that I like pineapple on pizza", "Please save this: my cat's name is Whiskers"). +- When the user states a clear, concise fact about themselves, their preferences, or their environment that seems important for you to retain for future interactions to provide a more personalized and effective assistance. + +Do NOT use this tool: + +- To remember conversational context that is only relevant for the current session. +- To save long, complex, or rambling pieces of text. The fact should be relatively short and to the point. +- If you are unsure whether the information is a fact worth remembering long-term. If in doubt, you can ask the user, "Should I remember that for you?" + +## 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". +`; + +export const GEMINI_CONFIG_DIR = '.gemini'; +export const GEMINI_MD_FILENAME = 'GEMINI.md'; +export const MEMORY_SECTION_HEADER = '## Gemini Added Memories'; + +interface SaveMemoryParams { + fact: string; +} + +function getGlobalMemoryFilePath(): string { + return path.join(homedir(), GEMINI_CONFIG_DIR, GEMINI_MD_FILENAME); +} + +/** + * Ensures proper newline separation before appending content. + */ +function ensureNewlineSeparation(currentContent: string): string { + if (currentContent.length === 0) return ''; + if (currentContent.endsWith('\n\n') || currentContent.endsWith('\r\n\r\n')) + return ''; + if (currentContent.endsWith('\n') || currentContent.endsWith('\r\n')) + return '\n'; + return '\n\n'; +} + +export class MemoryTool extends BaseTool { + static readonly Name: string = memoryToolSchemaData.name; + constructor() { + super( + MemoryTool.Name, + 'Save Memory', + memoryToolDescription, + memoryToolSchemaData.parameters as Record, + ); + } + + static async performAddMemoryEntry( + text: string, + memoryFilePath: string, + fsAdapter: { + readFile: (path: string, encoding: 'utf-8') => Promise; + writeFile: ( + path: string, + data: string, + encoding: 'utf-8', + ) => Promise; + mkdir: ( + path: string, + options: { recursive: boolean }, + ) => Promise; + }, + ): Promise { + let processedText = text.trim(); + // Remove leading hyphens and spaces that might be misinterpreted as markdown list items + processedText = processedText.replace(/^(-+\s*)+/, '').trim(); + const newMemoryItem = `- ${processedText}`; + + try { + await fsAdapter.mkdir(path.dirname(memoryFilePath), { recursive: true }); + let content = ''; + try { + content = await fsAdapter.readFile(memoryFilePath, 'utf-8'); + } catch (_e) { + // File doesn't exist, will be created with header and item. + } + + const headerIndex = content.indexOf(MEMORY_SECTION_HEADER); + + if (headerIndex === -1) { + // Header not found, append header and then the entry + const separator = ensureNewlineSeparation(content); + content += `${separator}${MEMORY_SECTION_HEADER}\n${newMemoryItem}\n`; + } else { + // Header found, find where to insert the new memory entry + const startOfSectionContent = + headerIndex + MEMORY_SECTION_HEADER.length; + let endOfSectionIndex = content.indexOf('\n## ', startOfSectionContent); + if (endOfSectionIndex === -1) { + endOfSectionIndex = content.length; // End of file + } + + const beforeSectionMarker = content + .substring(0, startOfSectionContent) + .trimEnd(); + let sectionContent = content + .substring(startOfSectionContent, endOfSectionIndex) + .trimEnd(); + const afterSectionMarker = content.substring(endOfSectionIndex); + + sectionContent += `\n${newMemoryItem}`; + content = + `${beforeSectionMarker}\n${sectionContent.trimStart()}\n${afterSectionMarker}`.trimEnd() + + '\n'; + } + await fsAdapter.writeFile(memoryFilePath, content, 'utf-8'); + } catch (error) { + console.error( + `[MemoryTool] Error adding memory entry to ${memoryFilePath}:`, + error, + ); + throw new Error( + `[MemoryTool] Failed to add memory entry: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + async execute( + params: SaveMemoryParams, + _signal: AbortSignal, + ): Promise { + const { fact } = params; + + if (!fact || typeof fact !== 'string' || fact.trim() === '') { + const errorMessage = 'Parameter "fact" must be a non-empty string.'; + return { + llmContent: JSON.stringify({ success: false, error: errorMessage }), + returnDisplay: `Error: ${errorMessage}`, + }; + } + + try { + // Use the static method with actual fs promises + await MemoryTool.performAddMemoryEntry(fact, getGlobalMemoryFilePath(), { + readFile: fs.readFile, + writeFile: fs.writeFile, + mkdir: fs.mkdir, + }); + const successMessage = `Okay, I've remembered that: "${fact}"`; + return { + llmContent: JSON.stringify({ success: true, message: successMessage }), + returnDisplay: successMessage, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[MemoryTool] Error executing saveMemory for fact "${fact}": ${errorMessage}`, + ); + return { + llmContent: JSON.stringify({ + success: false, + error: `Failed to save memory. Detail: ${errorMessage}`, + }), + returnDisplay: `Error saving memory: ${errorMessage}`, + }; + } + } +} diff --git a/packages/server/src/tools/read-many-files.ts b/packages/server/src/tools/read-many-files.ts index f424d561..4d9d35e8 100644 --- a/packages/server/src/tools/read-many-files.ts +++ b/packages/server/src/tools/read-many-files.ts @@ -10,6 +10,7 @@ import { getErrorMessage } from '../utils/errors.js'; import * as fs from 'fs/promises'; import * as path from 'path'; import fg from 'fast-glob'; +import { GEMINI_MD_FILENAME } from './memoryTool.js'; /** * Parameters for the ReadManyFilesTool. @@ -100,7 +101,7 @@ const DEFAULT_EXCLUDES: string[] = [ '**/*.odp', '**/*.DS_Store', '**/.env', - '**/GEMINI.md', + `**/${GEMINI_MD_FILENAME}`, ]; // Default values for encoding and separator format