feat(memory): make directory search limit on memory discovery configurable with settings.json (#4460)

This commit is contained in:
Brandon Keiji 2025-07-23 14:48:35 -07:00 committed by GitHub
parent 9d3164621a
commit d7a304bcff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 104 additions and 83 deletions

View File

@ -429,7 +429,7 @@ This example demonstrates how you can provide general project context, specific
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory. - Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
- Scope: Provides context relevant to the entire project or a significant portion of it. - Scope: Provides context relevant to the entire project or a significant portion of it.
3. **Sub-directory Context Files (Contextual/Local):** 3. **Sub-directory Context Files (Contextual/Local):**
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). - Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with a `memoryDiscoveryMaxDirs` field in your `settings.json` file.
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project. - Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt to the Gemini model. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context. - **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt to the Gemini model. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
- **Commands for Memory Management:** - **Commands for Memory Management:**

View File

@ -37,7 +37,7 @@ vi.mock('@google/gemini-cli-core', async () => {
...actualServer, ...actualServer,
loadEnvironment: vi.fn(), loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn( loadServerHierarchicalMemory: vi.fn(
(cwd, debug, fileService, extensionPaths) => (cwd, debug, fileService, extensionPaths, _maxDirs) =>
Promise.resolve({ Promise.resolve({
memoryContent: extensionPaths?.join(',') || '', memoryContent: extensionPaths?.join(',') || '',
fileCount: extensionPaths?.length || 0, fileCount: extensionPaths?.length || 0,
@ -491,6 +491,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGitIgnore: false, respectGitIgnore: false,
respectGeminiIgnore: true, respectGeminiIgnore: true,
}, },
undefined, // maxDirs
); );
}); });

View File

@ -225,6 +225,7 @@ export async function loadHierarchicalGeminiMemory(
currentWorkingDirectory: string, currentWorkingDirectory: string,
debugMode: boolean, debugMode: boolean,
fileService: FileDiscoveryService, fileService: FileDiscoveryService,
settings: Settings,
extensionContextFilePaths: string[] = [], extensionContextFilePaths: string[] = [],
fileFilteringOptions?: FileFilteringOptions, fileFilteringOptions?: FileFilteringOptions,
): Promise<{ memoryContent: string; fileCount: number }> { ): Promise<{ memoryContent: string; fileCount: number }> {
@ -242,6 +243,7 @@ export async function loadHierarchicalGeminiMemory(
fileService, fileService,
extensionContextFilePaths, extensionContextFilePaths,
fileFilteringOptions, fileFilteringOptions,
settings.memoryDiscoveryMaxDirs,
); );
} }
@ -298,6 +300,7 @@ export async function loadCliConfig(
process.cwd(), process.cwd(),
debugMode, debugMode,
fileService, fileService,
settings,
extensionContextFilePaths, extensionContextFilePaths,
fileFiltering, fileFiltering,
); );

View File

@ -100,6 +100,7 @@ export interface Settings {
// Add other settings here. // Add other settings here.
ideMode?: boolean; ideMode?: boolean;
memoryDiscoveryMaxDirs?: number;
} }
export interface SettingsError { export interface SettingsError {

View File

@ -236,6 +236,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
process.cwd(), process.cwd(),
config.getDebugMode(), config.getDebugMode(),
config.getFileService(), config.getFileService(),
settings.merged,
config.getExtensionContextFilePaths(), config.getExtensionContextFilePaths(),
config.getFileFilteringOptions(), config.getFileFilteringOptions(),
); );
@ -267,7 +268,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
); );
console.error('Error refreshing memory:', error); console.error('Error refreshing memory:', error);
} }
}, [config, addItem]); }, [config, addItem, settings.merged]);
// Watch for model changes (e.g., from Flash fallback) // Watch for model changes (e.g., from Flash fallback)
useEffect(() => { useEffect(() => {

View File

@ -9,7 +9,12 @@ import { memoryCommand } from './memoryCommand.js';
import { type CommandContext, SlashCommand } from './types.js'; import { type CommandContext, SlashCommand } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import { getErrorMessage } from '@google/gemini-cli-core'; import { LoadedSettings } from '../../config/settings.js';
import {
getErrorMessage,
loadServerHierarchicalMemory,
type FileDiscoveryService,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original = const original =
@ -20,9 +25,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
return String(error); return String(error);
}), }),
loadServerHierarchicalMemory: vi.fn(),
}; };
}); });
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
describe('memoryCommand', () => { describe('memoryCommand', () => {
let mockContext: CommandContext; let mockContext: CommandContext;
@ -139,19 +147,37 @@ describe('memoryCommand', () => {
describe('/memory refresh', () => { describe('/memory refresh', () => {
let refreshCommand: SlashCommand; let refreshCommand: SlashCommand;
let mockRefreshMemory: Mock; let mockSetUserMemory: Mock;
let mockSetGeminiMdFileCount: Mock;
beforeEach(() => { beforeEach(() => {
refreshCommand = getSubCommand('refresh'); refreshCommand = getSubCommand('refresh');
mockRefreshMemory = vi.fn(); mockSetUserMemory = vi.fn();
mockSetGeminiMdFileCount = vi.fn();
const mockConfig = {
setUserMemory: mockSetUserMemory,
setGeminiMdFileCount: mockSetGeminiMdFileCount,
getWorkingDir: () => '/test/dir',
getDebugMode: () => false,
getFileService: () => ({}) as FileDiscoveryService,
getExtensionContextFilePaths: () => [],
getFileFilteringOptions: () => ({
ignore: [],
include: [],
}),
};
mockContext = createMockCommandContext({ mockContext = createMockCommandContext({
services: { services: {
config: { config: Promise.resolve(mockConfig),
refreshMemory: mockRefreshMemory, settings: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any merged: {
} as any, memoryDiscoveryMaxDirs: 1000,
},
} as LoadedSettings,
}, },
}); });
mockLoadServerHierarchicalMemory.mockClear();
}); });
it('should display success message when memory is refreshed with content', async () => { it('should display success message when memory is refreshed with content', async () => {
@ -161,7 +187,7 @@ describe('memoryCommand', () => {
memoryContent: 'new memory content', memoryContent: 'new memory content',
fileCount: 2, fileCount: 2,
}; };
mockRefreshMemory.mockResolvedValue(refreshResult); mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult);
await refreshCommand.action(mockContext, ''); await refreshCommand.action(mockContext, '');
@ -173,7 +199,13 @@ describe('memoryCommand', () => {
expect.any(Number), expect.any(Number),
); );
expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).toHaveBeenCalledWith(
refreshResult.memoryContent,
);
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(
refreshResult.fileCount,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{ {
@ -188,11 +220,13 @@ describe('memoryCommand', () => {
if (!refreshCommand.action) throw new Error('Command has no action'); if (!refreshCommand.action) throw new Error('Command has no action');
const refreshResult = { memoryContent: '', fileCount: 0 }; const refreshResult = { memoryContent: '', fileCount: 0 };
mockRefreshMemory.mockResolvedValue(refreshResult); mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult);
await refreshCommand.action(mockContext, ''); await refreshCommand.action(mockContext, '');
expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).toHaveBeenCalledWith('');
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(0);
expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{ {
@ -207,11 +241,13 @@ describe('memoryCommand', () => {
if (!refreshCommand.action) throw new Error('Command has no action'); if (!refreshCommand.action) throw new Error('Command has no action');
const error = new Error('Failed to read memory files.'); const error = new Error('Failed to read memory files.');
mockRefreshMemory.mockRejectedValue(error); mockLoadServerHierarchicalMemory.mockRejectedValue(error);
await refreshCommand.action(mockContext, ''); await refreshCommand.action(mockContext, '');
expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).not.toHaveBeenCalled();
expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{ {
@ -243,7 +279,7 @@ describe('memoryCommand', () => {
expect.any(Number), expect.any(Number),
); );
expect(mockRefreshMemory).not.toHaveBeenCalled(); expect(loadServerHierarchicalMemory).not.toHaveBeenCalled();
}); });
}); });
}); });

View File

@ -4,7 +4,10 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { getErrorMessage } from '@google/gemini-cli-core'; import {
getErrorMessage,
loadServerHierarchicalMemory,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import { import {
CommandKind, CommandKind,
@ -81,10 +84,20 @@ export const memoryCommand: SlashCommand = {
); );
try { try {
const result = await context.services.config?.refreshMemory(); const config = await context.services.config;
if (config) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
if (result) {
const { memoryContent, fileCount } = result;
const successMessage = const successMessage =
memoryContent.length > 0 memoryContent.length > 0
? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).` ? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`

View File

@ -18,7 +18,6 @@ import {
} from '../core/contentGenerator.js'; } from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { GitService } from '../services/gitService.js'; import { GitService } from '../services/gitService.js';
import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
// Mock dependencies that might be called during Config construction or createServerConfig // Mock dependencies that might be called during Config construction or createServerConfig
vi.mock('../tools/tool-registry', () => { vi.mock('../tools/tool-registry', () => {
@ -313,39 +312,4 @@ describe('Server Config (config.ts)', () => {
expect(config.getTelemetryOtlpEndpoint()).toBe(DEFAULT_OTLP_ENDPOINT); expect(config.getTelemetryOtlpEndpoint()).toBe(DEFAULT_OTLP_ENDPOINT);
}); });
}); });
describe('refreshMemory', () => {
it('should update memory and file count on successful refresh', async () => {
const config = new Config(baseParams);
const mockMemoryData = {
memoryContent: 'new memory content',
fileCount: 5,
};
(loadServerHierarchicalMemory as Mock).mockResolvedValue(mockMemoryData);
const result = await config.refreshMemory();
expect(loadServerHierarchicalMemory).toHaveBeenCalledWith(
config.getWorkingDir(),
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getFileFilteringOptions(),
);
expect(config.getUserMemory()).toBe(mockMemoryData.memoryContent);
expect(config.getGeminiMdFileCount()).toBe(mockMemoryData.fileCount);
expect(result).toEqual(mockMemoryData);
});
it('should propagate errors from loadServerHierarchicalMemory', async () => {
const config = new Config(baseParams);
const testError = new Error('Failed to load memory');
(loadServerHierarchicalMemory as Mock).mockRejectedValue(testError);
await expect(config.refreshMemory()).rejects.toThrow(testError);
});
});
}); });

View File

@ -30,7 +30,6 @@ import { WebSearchTool } from '../tools/web-search.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GitService } from '../services/gitService.js'; import { GitService } from '../services/gitService.js';
import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import { getProjectTempDir } from '../utils/paths.js'; import { getProjectTempDir } from '../utils/paths.js';
import { import {
initializeTelemetry, initializeTelemetry,
@ -577,21 +576,6 @@ export class Config {
return this.gitService; return this.gitService;
} }
async refreshMemory(): Promise<{ memoryContent: string; fileCount: number }> {
const { memoryContent, fileCount } = await loadServerHierarchicalMemory(
this.getWorkingDir(),
this.getDebugMode(),
this.getFileService(),
this.getExtensionContextFilePaths(),
this.getFileFilteringOptions(),
);
this.setUserMemory(memoryContent);
this.setGeminiMdFileCount(fileCount);
return { memoryContent, fileCount };
}
async createToolRegistry(): Promise<ToolRegistry> { async createToolRegistry(): Promise<ToolRegistry> {
const registry = new ToolRegistry(this); const registry = new ToolRegistry(this);

View File

@ -319,18 +319,35 @@ My code memory
}); });
}); });
it('should respect MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY during downward scan', async () => { it('should respect the maxDirs parameter during downward scan', async () => {
// the max depth is 200 so it will give up before searching all these. const consoleDebugSpy = vi
for (let i = 0; i < 250; i++) { .spyOn(console, 'debug')
.mockImplementation(() => {});
for (let i = 0; i < 100; i++) {
await createEmptyDir(path.join(cwd, `deep_dir_${i}`)); await createEmptyDir(path.join(cwd, `deep_dir_${i}`));
} }
// "much_deeper" is alphabetically after "deep_dir_*" so it won't be loaded // Pass the custom limit directly to the function
await createTestFile( await loadServerHierarchicalMemory(
path.join(cwd, 'much_deeper', DEFAULT_CONTEXT_FILENAME), cwd,
'Ignored memory', true,
new FileDiscoveryService(projectRoot),
[],
{
respectGitIgnore: true,
respectGeminiIgnore: true,
},
50, // maxDirs
); );
expect(consoleDebugSpy).toHaveBeenCalledWith(
expect.stringContaining('[DEBUG] [BfsFileSearch]'),
expect.stringContaining('Scanning [50/50]:'),
);
vi.mocked(console.debug).mockRestore();
const result = await loadServerHierarchicalMemory( const result = await loadServerHierarchicalMemory(
cwd, cwd,
false, false,

View File

@ -33,8 +33,6 @@ const logger = {
console.error('[ERROR] [MemoryDiscovery]', ...args), console.error('[ERROR] [MemoryDiscovery]', ...args),
}; };
const MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY = 200;
interface GeminiFileContent { interface GeminiFileContent {
filePath: string; filePath: string;
content: string | null; content: string | null;
@ -90,6 +88,7 @@ async function getGeminiMdFilePathsInternal(
fileService: FileDiscoveryService, fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [], extensionContextFilePaths: string[] = [],
fileFilteringOptions: FileFilteringOptions, fileFilteringOptions: FileFilteringOptions,
maxDirs: number,
): Promise<string[]> { ): Promise<string[]> {
const allPaths = new Set<string>(); const allPaths = new Set<string>();
const geminiMdFilenames = getAllGeminiMdFilenames(); const geminiMdFilenames = getAllGeminiMdFilenames();
@ -194,7 +193,7 @@ async function getGeminiMdFilePathsInternal(
const downwardPaths = await bfsFileSearch(resolvedCwd, { const downwardPaths = await bfsFileSearch(resolvedCwd, {
fileName: geminiMdFilename, fileName: geminiMdFilename,
maxDirs: MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY, maxDirs,
debug: debugMode, debug: debugMode,
fileService, fileService,
fileFilteringOptions: mergedOptions, // Pass merged options as fileFilter fileFilteringOptions: mergedOptions, // Pass merged options as fileFilter
@ -295,6 +294,7 @@ export async function loadServerHierarchicalMemory(
fileService: FileDiscoveryService, fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [], extensionContextFilePaths: string[] = [],
fileFilteringOptions?: FileFilteringOptions, fileFilteringOptions?: FileFilteringOptions,
maxDirs: number = 200,
): Promise<{ memoryContent: string; fileCount: number }> { ): Promise<{ memoryContent: string; fileCount: number }> {
if (debugMode) if (debugMode)
logger.debug( logger.debug(
@ -311,6 +311,7 @@ export async function loadServerHierarchicalMemory(
fileService, fileService,
extensionContextFilePaths, extensionContextFilePaths,
fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
maxDirs,
); );
if (filePaths.length === 0) { if (filePaths.length === 0) {
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.'); if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');