From 2a1ad1f5d961b9f9593a6016eea7dd398bdeed0b Mon Sep 17 00:00:00 2001 From: Billy Biggs Date: Fri, 13 Jun 2025 09:19:08 -0700 Subject: [PATCH] Update contextFileName to support an optional list of strings (#1001) --- docs/cli/configuration.md | 4 +- packages/cli/src/config/extension.ts | 17 +- packages/cli/src/config/settings.ts | 2 +- packages/cli/src/ui/App.test.tsx | 23 +++ packages/cli/src/ui/App.tsx | 15 +- .../ui/components/ContextSummaryDisplay.tsx | 8 +- packages/core/src/config/config.ts | 2 +- packages/core/src/tools/memoryTool.test.ts | 8 + packages/core/src/tools/memoryTool.ts | 20 +- packages/core/src/utils/memoryDiscovery.ts | 194 +++++++++--------- 10 files changed, 176 insertions(+), 117 deletions(-) diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 4eacc6fd..1b880e0f 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -38,9 +38,9 @@ When you create a `.gemini/settings.json` file for project-specific settings, or ### Available Settings in `settings.json`: -- **`contextFileName`** (string, optional): +- **`contextFileName`** (string or array of strings, optional): - - **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`). + - **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`). May be a single filename or a list of accepted filenames. - **Default:** `GEMINI.md` - **Example:** `"contextFileName": "AGENTS.md"` diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 9dd33e1b..685c0b74 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -16,7 +16,7 @@ export interface ExtensionConfig { name: string; version: string; mcpServers?: Record; - contextFileName?: string; + contextFileName?: string | string[]; } export function loadExtensions(workspaceDir: string): ExtensionConfig[] { @@ -76,12 +76,15 @@ function loadExtensionsFromDir(dir: string): ExtensionConfig[] { } if (extensionConfig.contextFileName) { - const contextFilePath = path.join( - extensionDir, - extensionConfig.contextFileName, - ); - if (fs.existsSync(contextFilePath)) { - extensionConfig.contextFileName = contextFilePath; + const contextFileNames = Array.isArray(extensionConfig.contextFileName) + ? extensionConfig.contextFileName + : [extensionConfig.contextFileName]; + const resolvedPaths = contextFileNames + .map((fileName) => path.join(extensionDir, fileName)) + .filter((filePath) => fs.existsSync(filePath)); + if (resolvedPaths.length > 0) { + extensionConfig.contextFileName = + resolvedPaths.length === 1 ? resolvedPaths[0] : resolvedPaths; } } else { const contextFilePath = path.join(extensionDir, 'gemini.md'); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index e00ebb79..25f9d79d 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -35,7 +35,7 @@ export interface Settings { mcpServerCommand?: string; mcpServers?: Record; showMemoryUsage?: boolean; - contextFileName?: string; + contextFileName?: string | string[]; accessibility?: AccessibilitySettings; telemetry?: boolean; preferredEditor?: string; diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index b8959bfb..201d0698 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -64,6 +64,7 @@ interface MockServerConfig { getShowMemoryUsage: Mock<() => boolean>; getAccessibility: Mock<() => AccessibilitySettings>; getProjectRoot: Mock<() => string | undefined>; + getAllGeminiMdFilenames: Mock<() => string[]>; } // Mock @gemini-cli/core and its Config class @@ -124,12 +125,14 @@ vi.mock('@gemini-cli/core', async (importOriginal) => { getProjectRoot: vi.fn(() => opts.projectRoot), getGeminiClient: vi.fn(() => ({})), getCheckpointEnabled: vi.fn(() => opts.checkpoint ?? true), + getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']), }; }); return { ...actualCore, Config: ConfigClassMock, MCPServerConfig: actualCore.MCPServerConfig, + getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']), }; }); @@ -269,6 +272,26 @@ describe('App UI', () => { expect(lastFrame()).toContain('Using 1 AGENTS.MD file'); }); + it('should display the first custom contextFileName when an array is provided', async () => { + mockSettings = createMockSettings({ + contextFileName: ['AGENTS.MD', 'CONTEXT.MD'], + theme: 'Default', + }); + mockConfig.getGeminiMdFileCount.mockReturnValue(2); + mockConfig.getDebugMode.mockReturnValue(false); + mockConfig.getShowMemoryUsage.mockReturnValue(false); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + await Promise.resolve(); + expect(lastFrame()).toContain('Using 2 AGENTS.MD files'); + }); + it('should display custom contextFileName with plural when set and count is > 1', async () => { mockSettings = createMockSettings({ contextFileName: 'MY_NOTES.TXT', diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 2d37c42a..c7ed9a81 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -45,7 +45,7 @@ import process from 'node:process'; import { getErrorMessage, type Config, - getCurrentGeminiMdFilename, + getAllGeminiMdFilenames, ApprovalMode, isEditorAvailable, EditorType, @@ -373,6 +373,14 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { const branchName = useGitBranchName(config.getTargetDir()); + const contextFileNames = useMemo(() => { + const fromSettings = settings.merged.contextFileName; + if (fromSettings) { + return Array.isArray(fromSettings) ? fromSettings : [fromSettings]; + } + return getAllGeminiMdFilenames(); + }, [settings.merged.contextFileName]); + if (quittingMessages) { return ( @@ -509,10 +517,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { ) : ( diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index c4527066..904bf81f 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -11,14 +11,14 @@ import { type MCPServerConfig } from '@gemini-cli/core'; interface ContextSummaryDisplayProps { geminiMdFileCount: number; - contextFileName: string; + contextFileNames: string[]; mcpServers?: Record; showToolDescriptions?: boolean; } export const ContextSummaryDisplay: React.FC = ({ geminiMdFileCount, - contextFileName, + contextFileNames, mcpServers, showToolDescriptions, }) => { @@ -30,7 +30,9 @@ export const ContextSummaryDisplay: React.FC = ({ const geminiMdText = geminiMdFileCount > 0 - ? `${geminiMdFileCount} ${contextFileName} file${geminiMdFileCount > 1 ? 's' : ''}` + ? `${geminiMdFileCount} ${contextFileNames[0]} file${ + geminiMdFileCount > 1 ? 's' : '' + }` : ''; const mcpText = diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2576080b..4962d2a7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -74,7 +74,7 @@ export interface ConfigParameters { geminiMdFileCount?: number; approvalMode?: ApprovalMode; showMemoryUsage?: boolean; - contextFileName?: string; + contextFileName?: string | string[]; geminiIgnorePatterns?: string[]; accessibility?: AccessibilitySettings; telemetry?: boolean; diff --git a/packages/core/src/tools/memoryTool.test.ts b/packages/core/src/tools/memoryTool.test.ts index 612a08dc..aff0cc2e 100644 --- a/packages/core/src/tools/memoryTool.test.ts +++ b/packages/core/src/tools/memoryTool.test.ts @@ -9,6 +9,7 @@ import { MemoryTool, setGeminiMdFilename, getCurrentGeminiMdFilename, + getAllGeminiMdFilenames, DEFAULT_CONTEXT_FILENAME, } from './memoryTool.js'; import * as fs from 'fs/promises'; @@ -74,6 +75,13 @@ describe('MemoryTool', () => { setGeminiMdFilename(''); expect(getCurrentGeminiMdFilename()).toBe(initialName); }); + + it('should handle an array of filenames', () => { + const newNames = ['CUSTOM_CONTEXT.md', 'ANOTHER_CONTEXT.md']; + setGeminiMdFilename(newNames); + expect(getCurrentGeminiMdFilename()).toBe('CUSTOM_CONTEXT.md'); + expect(getAllGeminiMdFilenames()).toEqual(newNames); + }); }); describe('performAddMemoryEntry (static method)', () => { diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index a0c62eae..2c6f41c8 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -51,18 +51,32 @@ export const MEMORY_SECTION_HEADER = '## Gemini Added Memories'; // This variable will hold the currently configured filename for GEMINI.md context files. // It defaults to DEFAULT_CONTEXT_FILENAME but can be overridden by setGeminiMdFilename. -let currentGeminiMdFilename = DEFAULT_CONTEXT_FILENAME; +let currentGeminiMdFilename: string | string[] = DEFAULT_CONTEXT_FILENAME; -export function setGeminiMdFilename(newFilename: string): void { - if (newFilename && newFilename.trim() !== '') { +export function setGeminiMdFilename(newFilename: string | string[]): void { + if (Array.isArray(newFilename)) { + if (newFilename.length > 0) { + currentGeminiMdFilename = newFilename.map((name) => name.trim()); + } + } else if (newFilename && newFilename.trim() !== '') { currentGeminiMdFilename = newFilename.trim(); } } export function getCurrentGeminiMdFilename(): string { + if (Array.isArray(currentGeminiMdFilename)) { + return currentGeminiMdFilename[0]; + } return currentGeminiMdFilename; } +export function getAllGeminiMdFilenames(): string[] { + if (Array.isArray(currentGeminiMdFilename)) { + return currentGeminiMdFilename; + } + return [currentGeminiMdFilename]; +} + interface SaveMemoryParams { fact: string; } diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 07649415..2180b7a3 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -11,7 +11,7 @@ import { homedir } from 'os'; import { bfsFileSearch } from './bfsFileSearch.js'; import { GEMINI_CONFIG_DIR, - getCurrentGeminiMdFilename, + getAllGeminiMdFilenames, } from '../tools/memoryTool.js'; // Simple console logger, similar to the one previously in CLI's config.ts @@ -83,131 +83,135 @@ async function getGeminiMdFilePathsInternal( debugMode: boolean, extensionContextFilePaths: string[] = [], ): Promise { - const resolvedCwd = path.resolve(currentWorkingDirectory); - const resolvedHome = path.resolve(userHomePath); - const globalMemoryPath = path.join( - resolvedHome, - GEMINI_CONFIG_DIR, - getCurrentGeminiMdFilename(), - ); - const paths: string[] = []; + const allPaths = new Set(); + const geminiMdFilenames = getAllGeminiMdFilenames(); - if (debugMode) - logger.debug( - `Searching for ${getCurrentGeminiMdFilename()} starting from CWD: ${resolvedCwd}`, + for (const geminiMdFilename of geminiMdFilenames) { + const resolvedCwd = path.resolve(currentWorkingDirectory); + const resolvedHome = path.resolve(userHomePath); + const globalMemoryPath = path.join( + resolvedHome, + GEMINI_CONFIG_DIR, + geminiMdFilename, ); - if (debugMode) logger.debug(`User home directory: ${resolvedHome}`); - try { - await fs.access(globalMemoryPath, fsSync.constants.R_OK); - paths.push(globalMemoryPath); if (debugMode) logger.debug( - `Found readable global ${getCurrentGeminiMdFilename()}: ${globalMemoryPath}`, + `Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`, ); - } catch { - if (debugMode) - logger.debug( - `Global ${getCurrentGeminiMdFilename()} not found or not readable: ${globalMemoryPath}`, - ); - } + if (debugMode) logger.debug(`User home directory: ${resolvedHome}`); - const projectRoot = await findProjectRoot(resolvedCwd); - if (debugMode) - logger.debug(`Determined project root: ${projectRoot ?? 'None'}`); - - const upwardPaths: string[] = []; - let currentDir = resolvedCwd; - // Determine the directory that signifies the top of the project or user-specific space. - const ultimateStopDir = projectRoot - ? path.dirname(projectRoot) - : path.dirname(resolvedHome); - - while (currentDir && currentDir !== path.dirname(currentDir)) { - // Loop until filesystem root or currentDir is empty - if (debugMode) { - logger.debug( - `Checking for ${getCurrentGeminiMdFilename()} in (upward scan): ${currentDir}`, - ); + try { + await fs.access(globalMemoryPath, fsSync.constants.R_OK); + allPaths.add(globalMemoryPath); + if (debugMode) + logger.debug( + `Found readable global ${geminiMdFilename}: ${globalMemoryPath}`, + ); + } catch { + if (debugMode) + logger.debug( + `Global ${geminiMdFilename} not found or not readable: ${globalMemoryPath}`, + ); } - // Skip the global .gemini directory itself during upward scan from CWD, - // as global is handled separately and explicitly first. - if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) { + const projectRoot = await findProjectRoot(resolvedCwd); + if (debugMode) + logger.debug(`Determined project root: ${projectRoot ?? 'None'}`); + + const upwardPaths: string[] = []; + let currentDir = resolvedCwd; + // Determine the directory that signifies the top of the project or user-specific space. + const ultimateStopDir = projectRoot + ? path.dirname(projectRoot) + : path.dirname(resolvedHome); + + while (currentDir && currentDir !== path.dirname(currentDir)) { + // Loop until filesystem root or currentDir is empty if (debugMode) { logger.debug( - `Upward scan reached global config dir path, stopping upward search here: ${currentDir}`, + `Checking for ${geminiMdFilename} in (upward scan): ${currentDir}`, ); } - break; - } - const potentialPath = path.join(currentDir, getCurrentGeminiMdFilename()); - try { - await fs.access(potentialPath, fsSync.constants.R_OK); - // Add to upwardPaths only if it's not the already added globalMemoryPath - if (potentialPath !== globalMemoryPath) { - upwardPaths.unshift(potentialPath); + // Skip the global .gemini directory itself during upward scan from CWD, + // as global is handled separately and explicitly first. + if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) { if (debugMode) { logger.debug( - `Found readable upward ${getCurrentGeminiMdFilename()}: ${potentialPath}`, + `Upward scan reached global config dir path, stopping upward search here: ${currentDir}`, + ); + } + break; + } + + const potentialPath = path.join(currentDir, geminiMdFilename); + try { + await fs.access(potentialPath, fsSync.constants.R_OK); + // Add to upwardPaths only if it's not the already added globalMemoryPath + if (potentialPath !== globalMemoryPath) { + upwardPaths.unshift(potentialPath); + if (debugMode) { + logger.debug( + `Found readable upward ${geminiMdFilename}: ${potentialPath}`, + ); + } + } + } catch { + if (debugMode) { + logger.debug( + `Upward ${geminiMdFilename} not found or not readable in: ${currentDir}`, ); } } - } catch { - if (debugMode) { - logger.debug( - `Upward ${getCurrentGeminiMdFilename()} not found or not readable in: ${currentDir}`, - ); + + // Stop condition: if currentDir is the ultimateStopDir, break after this iteration. + if (currentDir === ultimateStopDir) { + if (debugMode) + logger.debug( + `Reached ultimate stop directory for upward scan: ${currentDir}`, + ); + break; } + + currentDir = path.dirname(currentDir); } + upwardPaths.forEach((p) => allPaths.add(p)); - // Stop condition: if currentDir is the ultimateStopDir, break after this iteration. - if (currentDir === ultimateStopDir) { - if (debugMode) - logger.debug( - `Reached ultimate stop directory for upward scan: ${currentDir}`, - ); - break; - } - - currentDir = path.dirname(currentDir); - } - paths.push(...upwardPaths); - - const downwardPaths = await bfsFileSearch(resolvedCwd, { - fileName: getCurrentGeminiMdFilename(), - maxDirs: MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY, - debug: debugMode, - respectGitIgnore: true, - projectRoot: projectRoot || resolvedCwd, - }); - downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex - if (debugMode && downwardPaths.length > 0) - logger.debug( - `Found downward ${getCurrentGeminiMdFilename()} files (sorted): ${JSON.stringify( - downwardPaths, - )}`, - ); - // Add downward paths only if they haven't been included already (e.g. from upward scan) - for (const dPath of downwardPaths) { - if (!paths.includes(dPath)) { - paths.push(dPath); + const downwardPaths = await bfsFileSearch(resolvedCwd, { + fileName: geminiMdFilename, + maxDirs: MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY, + debug: debugMode, + respectGitIgnore: true, + projectRoot: projectRoot || resolvedCwd, + }); + downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex + if (debugMode && downwardPaths.length > 0) + logger.debug( + `Found downward ${geminiMdFilename} files (sorted): ${JSON.stringify( + downwardPaths, + )}`, + ); + // Add downward paths only if they haven't been included already (e.g. from upward scan) + for (const dPath of downwardPaths) { + allPaths.add(dPath); } } // Add extension context file paths for (const extensionPath of extensionContextFilePaths) { - if (!paths.includes(extensionPath)) { - paths.push(extensionPath); - } + allPaths.add(extensionPath); } + const finalPaths = Array.from(allPaths); + if (debugMode) logger.debug( - `Final ordered ${getCurrentGeminiMdFilename()} paths to read: ${JSON.stringify(paths)}`, + `Final ordered ${getAllGeminiMdFilenames()} paths to read: ${JSON.stringify( + finalPaths, + )}`, ); - return paths; + return finalPaths; } async function readGeminiMdFiles( @@ -228,7 +232,7 @@ async function readGeminiMdFiles( if (!isTestEnv) { const message = error instanceof Error ? error.message : String(error); logger.warn( - `Warning: Could not read ${getCurrentGeminiMdFilename()} file at ${filePath}. Error: ${message}`, + `Warning: Could not read ${getAllGeminiMdFilenames()} file at ${filePath}. Error: ${message}`, ); } results.push({ filePath, content: null }); // Still include it with null content