diff options
| author | Allen Hutchison <[email protected]> | 2025-05-16 16:36:50 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-05-16 16:36:50 -0700 |
| commit | 1bdec55fe1c658069a45df0aa8e4923ba1954e41 (patch) | |
| tree | 32aa334cb0590f06830f52ed7c0d84e2d4ed7db3 /packages/server/src | |
| parent | d9bd2b0e144560c8a82806bfb021a028c7cd43c9 (diff) | |
feat: Implement CLI and model memory management (#371)
Co-authored-by: N. Taylor Mullen <[email protected]>
Diffstat (limited to 'packages/server/src')
| -rw-r--r-- | packages/server/src/config/config.ts | 4 | ||||
| -rw-r--r-- | packages/server/src/core/__snapshots__/prompts.test.ts.snap | 7 | ||||
| -rw-r--r-- | packages/server/src/core/prompts.ts | 2 | ||||
| -rw-r--r-- | packages/server/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/server/src/tools/memoryTool.test.ts | 224 | ||||
| -rw-r--r-- | packages/server/src/tools/memoryTool.ts | 194 | ||||
| -rw-r--r-- | packages/server/src/tools/read-many-files.ts | 3 |
7 files changed, 433 insertions, 2 deletions
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 = '[email protected]'; @@ -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<string>; + writeFile: (path: string, data: string, encoding: 'utf-8') => Promise<void>; + mkdir: ( + path: string, + options: { recursive: boolean }, + ) => Promise<string | undefined>; +} + +describe('MemoryTool', () => { + const mockAbortSignal = new AbortController().signal; + + const mockFsAdapter: { + readFile: Mock<FsAdapter['readFile']>; + writeFile: Mock<FsAdapter['writeFile']>; + mkdir: Mock<FsAdapter['mkdir']>; + } = { + 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<typeof MemoryTool.performAddMemoryEntry>; + + 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<SaveMemoryParams, ToolResult> { + static readonly Name: string = memoryToolSchemaData.name; + constructor() { + super( + MemoryTool.Name, + 'Save Memory', + memoryToolDescription, + memoryToolSchemaData.parameters as Record<string, unknown>, + ); + } + + static async performAddMemoryEntry( + text: string, + memoryFilePath: string, + fsAdapter: { + readFile: (path: string, encoding: 'utf-8') => Promise<string>; + writeFile: ( + path: string, + data: string, + encoding: 'utf-8', + ) => Promise<void>; + mkdir: ( + path: string, + options: { recursive: boolean }, + ) => Promise<string | undefined>; + }, + ): Promise<void> { + 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<ToolResult> { + 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 |
