diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index ce9b55bc..5c917a3f 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -248,6 +248,26 @@ In addition to a project settings file, a project's `.gemini` directory can cont "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] ``` +- **`includeDirectories`** (array of strings): + - **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. This allows you to work with files across multiple directories as if they were one. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. + - **Default:** `[]` + - **Example:** + ```json + "includeDirectories": [ + "/path/to/another/project", + "../shared-library", + "~/common-utils" + ] + ``` + +- **`loadMemoryFromIncludeDirectories`** (boolean): + - **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `GEMINI.md` files should be loaded from all directories that are added. If set to `false`, `GEMINI.md` should only be loaded from the current directory. + - **Default:** `false` + - **Example:** + ```json + "loadMemoryFromIncludeDirectories": true + ``` + ### Example `settings.json`: ```json @@ -280,7 +300,9 @@ In addition to a project settings file, a project's `.gemini` directory can cont "tokenBudget": 100 } }, - "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] + "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"], + "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], + "loadMemoryFromIncludeDirectories": true } ``` diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 431b1375..f5d0ddf8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -6,6 +6,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; import { loadCliConfig, parseArguments } from './config.js'; import { Settings } from './settings.js'; import { Extension } from './extension.js'; @@ -44,7 +46,7 @@ vi.mock('@google/gemini-cli-core', async () => { }, loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( - (cwd, debug, fileService, extensionPaths, _maxDirs) => + (cwd, dirs, debug, fileService, extensionPaths, _maxDirs) => Promise.resolve({ memoryContent: extensionPaths?.join(',') || '', fileCount: extensionPaths?.length || 0, @@ -487,6 +489,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { await loadCliConfig(settings, extensions, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), + [], false, expect.any(Object), [ @@ -1015,3 +1018,85 @@ describe('loadCliConfig ideModeFeature', () => { expect(config.getIdeModeFeature()).toBe(false); }); }); + +vi.mock('fs', async () => { + const actualFs = await vi.importActual('fs'); + const MOCK_CWD1 = process.cwd(); + const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project'); + + const mockPaths = new Set([ + MOCK_CWD1, + MOCK_CWD2, + path.resolve(path.sep, 'cli', 'path1'), + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(MOCK_CWD2, 'cli', 'path2'), + path.join(MOCK_CWD2, 'settings', 'path3'), + ]); + + return { + ...actualFs, + existsSync: vi.fn((p) => mockPaths.has(p.toString())), + statSync: vi.fn((p) => { + if (mockPaths.has(p.toString())) { + return { isDirectory: () => true }; + } + // Fallback for other paths if needed, though the test should be specific. + return actualFs.statSync(p); + }), + realpathSync: vi.fn((p) => p), + }; +}); + +describe('loadCliConfig with includeDirectories', () => { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + process.env.GEMINI_API_KEY = 'test-api-key'; + vi.spyOn(process, 'cwd').mockReturnValue( + path.resolve(path.sep, 'home', 'user', 'project'), + ); + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('should combine and resolve paths from settings and CLI arguments', async () => { + const mockCwd = path.resolve(path.sep, 'home', 'user', 'project'); + process.argv = [ + 'node', + 'script.js', + '--include-directories', + `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, + ]; + const argv = await parseArguments(); + const settings: Settings = { + includeDirectories: [ + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(mockCwd, 'settings', 'path3'), + ], + }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + const expected = [ + mockCwd, + path.resolve(path.sep, 'cli', 'path1'), + path.join(mockCwd, 'cli', 'path2'), + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(mockCwd, 'settings', 'path3'), + ]; + expect(config.getWorkspaceContext().getDirectories()).toEqual( + expect.arrayContaining(expected), + ); + expect(config.getWorkspaceContext().getDirectories()).toHaveLength( + expected.length, + ); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 0395ac0f..d3d37c6a 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -29,6 +29,7 @@ import { Settings } from './settings.js'; import { Extension, annotateActiveExtensions } from './extension.js'; import { getCliVersion } from '../utils/version.js'; import { loadSandboxConfig } from './sandboxConfig.js'; +import { resolvePath } from '../utils/resolvePath.js'; // Simple console logger for now - replace with actual logger if available const logger = { @@ -65,6 +66,7 @@ export interface CliArgs { ideModeFeature: boolean | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; + loadMemoryFromIncludeDirectories: boolean | undefined; } export async function parseArguments(): Promise { @@ -212,6 +214,12 @@ export async function parseArguments(): Promise { // Handle comma-separated values dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), }) + .option('load-memory-from-include-directories', { + type: 'boolean', + description: + 'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.', + default: false, + }) .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() @@ -239,6 +247,7 @@ export async function parseArguments(): Promise { // TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself. export async function loadHierarchicalGeminiMemory( currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[] = [], debugMode: boolean, fileService: FileDiscoveryService, settings: Settings, @@ -264,6 +273,7 @@ export async function loadHierarchicalGeminiMemory( // Directly call the server function with the corrected path. return loadServerHierarchicalMemory( effectiveCwd, + includeDirectoriesToReadGemini, debugMode, fileService, extensionContextFilePaths, @@ -325,9 +335,14 @@ export async function loadCliConfig( ...settings.fileFiltering, }; + const includeDirectories = (settings.includeDirectories || []) + .map(resolvePath) + .concat((argv.includeDirectories || []).map(resolvePath)); + // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( process.cwd(), + settings.loadMemoryFromIncludeDirectories ? includeDirectories : [], debugMode, fileService, settings, @@ -393,7 +408,11 @@ export async function loadCliConfig( embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: sandboxConfig, targetDir: process.cwd(), - includeDirectories: argv.includeDirectories, + includeDirectories, + loadMemoryFromIncludeDirectories: + argv.loadMemoryFromIncludeDirectories || + settings.loadMemoryFromIncludeDirectories || + false, debugMode, question: argv.promptInteractive || argv.prompt || '', fullContext: argv.allFiles || argv.all_files || false, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 4099e778..d0266720 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -112,6 +112,7 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({ customThemes: {}, mcpServers: {}, + includeDirectories: [], }); expect(settings.errors.length).toBe(0); }); @@ -145,6 +146,7 @@ describe('Settings Loading and Merging', () => { ...systemSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -178,6 +180,7 @@ describe('Settings Loading and Merging', () => { ...userSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -209,6 +212,7 @@ describe('Settings Loading and Merging', () => { ...workspaceSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -246,6 +250,7 @@ describe('Settings Loading and Merging', () => { contextFileName: 'WORKSPACE_CONTEXT.md', customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -295,6 +300,7 @@ describe('Settings Loading and Merging', () => { allowMCPServers: ['server1', 'server2'], customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -616,6 +622,40 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.mcpServers).toEqual({}); }); + it('should merge includeDirectories from all scopes', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + includeDirectories: ['/system/dir'], + }; + const userSettingsContent = { + includeDirectories: ['/user/dir1', '/user/dir2'], + }; + const workspaceSettingsContent = { + includeDirectories: ['/workspace/dir'], + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) + return JSON.stringify(systemSettingsContent); + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.includeDirectories).toEqual([ + '/system/dir', + '/user/dir1', + '/user/dir2', + '/workspace/dir', + ]); + }); + it('should handle JSON parsing errors gracefully', () => { (mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist" const invalidJsonContent = 'invalid json'; @@ -654,6 +694,7 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({ customThemes: {}, mcpServers: {}, + includeDirectories: [], }); // Check that error objects are populated in settings.errors @@ -1090,6 +1131,7 @@ describe('Settings Loading and Merging', () => { ...systemSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 20a7b14a..722af628 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -126,6 +126,10 @@ export interface Settings { // Environment variables to exclude from project .env files excludedProjectEnvVars?: string[]; dnsResolutionOrder?: DnsResolutionOrder; + + includeDirectories?: string[]; + + loadMemoryFromIncludeDirectories?: boolean; } export interface SettingsError { @@ -181,6 +185,11 @@ export class LoadedSettings { ...(workspace.mcpServers || {}), ...(system.mcpServers || {}), }, + includeDirectories: [ + ...(system.includeDirectories || []), + ...(user.includeDirectories || []), + ...(workspace.includeDirectories || []), + ], }; } diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 3b695111..f07a5386 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -276,6 +276,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { try { const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( process.cwd(), + settings.merged.loadMemoryFromIncludeDirectories + ? config.getWorkspaceContext().getDirectories() + : [], config.getDebugMode(), config.getFileService(), settings.merged, @@ -480,6 +483,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { openPrivacyNotice, toggleVimEnabled, setIsProcessing, + setGeminiMdFileCount, ); const { @@ -599,7 +603,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if (config) { setGeminiMdFileCount(config.getGeminiMdFileCount()); } - }, [config]); + }, [config, config.getGeminiMdFileCount]); const logger = useLogger(); const [userMessages, setUserMessages] = useState([]); diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 081083d3..fee8ae40 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -40,11 +40,24 @@ describe('directoryCommand', () => { getGeminiClient: vi.fn().mockReturnValue({ addDirectoryContext: vi.fn(), }), + getWorkingDir: () => '/test/dir', + shouldLoadMemoryFromIncludeDirectories: () => false, + getDebugMode: () => false, + getFileService: () => ({}), + getExtensionContextFilePaths: () => [], + getFileFilteringOptions: () => ({ ignore: [], include: [] }), + setUserMemory: vi.fn(), + setGeminiMdFileCount: vi.fn(), } as unknown as Config; mockContext = { services: { config: mockConfig, + settings: { + merged: { + memoryDiscoveryMaxDirs: 1000, + }, + }, }, ui: { addItem: vi.fn(), diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 18f7e78f..6c667f44 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -8,6 +8,7 @@ import { SlashCommand, CommandContext, CommandKind } from './types.js'; import { MessageType } from '../types.js'; import * as os from 'os'; import * as path from 'path'; +import { loadServerHierarchicalMemory } from '@google/gemini-cli-core'; export function expandHomeDir(p: string): string { if (!p) { @@ -16,7 +17,7 @@ export function expandHomeDir(p: string): string { let expandedPath = p; if (p.toLowerCase().startsWith('%userprofile%')) { expandedPath = os.homedir() + p.substring('%userprofile%'.length); - } else if (p.startsWith('~')) { + } else if (p === '~' || p.startsWith('~/')) { expandedPath = os.homedir() + p.substring(1); } return path.normalize(expandedPath); @@ -90,6 +91,37 @@ export const directoryCommand: SlashCommand = { } } + try { + if (config.shouldLoadMemoryFromIncludeDirectories()) { + const { memoryContent, fileCount } = + await loadServerHierarchicalMemory( + config.getWorkingDir(), + [ + ...config.getWorkspaceContext().getDirectories(), + ...pathsToAdd, + ], + config.getDebugMode(), + config.getFileService(), + config.getExtensionContextFilePaths(), + context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree' + config.getFileFilteringOptions(), + context.services.settings.merged.memoryDiscoveryMaxDirs, + ); + config.setUserMemory(memoryContent); + config.setGeminiMdFileCount(fileCount); + context.ui.setGeminiMdFileCount(fileCount); + } + addItem( + { + type: MessageType.INFO, + text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`, + }, + Date.now(), + ); + } catch (error) { + errors.push(`Error refreshing memory: ${(error as Error).message}`); + } + if (added.length > 0) { const gemini = config.getGeminiClient(); if (gemini) { diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 74614fa7..670ca796 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -161,6 +161,10 @@ describe('memoryCommand', () => { getDebugMode: () => false, getFileService: () => ({}) as FileDiscoveryService, getExtensionContextFilePaths: () => [], + shouldLoadMemoryFromIncludeDirectories: () => false, + getWorkspaceContext: () => ({ + getDirectories: () => [], + }), getFileFilteringOptions: () => ({ ignore: [], include: [], diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 370bb1fb..b046e7f8 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -89,6 +89,9 @@ export const memoryCommand: SlashCommand = { const { memoryContent, fileCount } = await loadServerHierarchicalMemory( config.getWorkingDir(), + config.shouldLoadMemoryFromIncludeDirectories() + ? config.getWorkspaceContext().getDirectories() + : [], config.getDebugMode(), config.getFileService(), config.getExtensionContextFilePaths(), diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2de221f0..09d79e9d 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -59,6 +59,7 @@ export interface CommandContext { /** Toggles a special display mode. */ toggleCorgiMode: () => void; toggleVimEnabled: () => Promise; + setGeminiMdFileCount: (count: number) => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6d9f4643..cfe4b385 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -51,6 +51,7 @@ export const useSlashCommandProcessor = ( openPrivacyNotice: () => void, toggleVimEnabled: () => Promise, setIsProcessing: (isProcessing: boolean) => void, + setGeminiMdFileCount: (count: number) => void, ) => { const session = useSessionStats(); const [commands, setCommands] = useState([]); @@ -163,6 +164,7 @@ export const useSlashCommandProcessor = ( setPendingItem: setPendingCompressionItem, toggleCorgiMode, toggleVimEnabled, + setGeminiMdFileCount, }, session: { stats: session.stats, @@ -185,6 +187,7 @@ export const useSlashCommandProcessor = ( toggleCorgiMode, toggleVimEnabled, sessionShellAllowlist, + setGeminiMdFileCount, ], ); diff --git a/packages/cli/src/utils/resolvePath.ts b/packages/cli/src/utils/resolvePath.ts new file mode 100644 index 00000000..b26ed8fc --- /dev/null +++ b/packages/cli/src/utils/resolvePath.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as os from 'os'; +import * as path from 'path'; + +export function resolvePath(p: string): string { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = os.homedir() + p.substring('%userprofile%'.length); + } else if (p === '~' || p.startsWith('~/')) { + expandedPath = os.homedir() + p.substring(1); + } + return path.normalize(expandedPath); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3f5c11a0..22996f3e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -188,6 +188,7 @@ export interface ConfigParameters { ideModeFeature?: boolean; ideMode?: boolean; ideClient: IdeClient; + loadMemoryFromIncludeDirectories?: boolean; } export class Config { @@ -247,6 +248,7 @@ export class Config { | Record | undefined; private readonly experimentalAcp: boolean = false; + private readonly loadMemoryFromIncludeDirectories: boolean = false; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -304,6 +306,8 @@ export class Config { this.ideModeFeature = params.ideModeFeature ?? false; this.ideMode = params.ideMode ?? false; this.ideClient = params.ideClient; + this.loadMemoryFromIncludeDirectories = + params.loadMemoryFromIncludeDirectories ?? false; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -366,6 +370,10 @@ export class Config { return this.sessionId; } + shouldLoadMemoryFromIncludeDirectories(): boolean { + return this.loadMemoryFromIncludeDirectories; + } + getContentGeneratorConfig(): ContentGeneratorConfig { return this.contentGeneratorConfig; } diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 8c7a294d..6c229dbb 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -67,6 +67,7 @@ describe('loadServerHierarchicalMemory', () => { it('should return empty memory and count if no context files are found', async () => { const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); @@ -85,14 +86,13 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} --- -default context content ---- End of Context from: ${path.relative(cwd, defaultContextFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---`, fileCount: 1, }); }); @@ -108,14 +108,13 @@ default context content const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, customContextFile)} --- -custom context content ---- End of Context from: ${path.relative(cwd, customContextFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, customContextFile)} ---\ncustom context content\n--- End of Context from: ${path.relative(cwd, customContextFile)} ---`, fileCount: 1, }); }); @@ -135,18 +134,13 @@ custom context content const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, projectContextFile)} --- -project context content ---- End of Context from: ${path.relative(cwd, projectContextFile)} --- - ---- Context from: ${path.relative(cwd, cwdContextFile)} --- -cwd context content ---- End of Context from: ${path.relative(cwd, cwdContextFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, projectContextFile)} ---\nproject context content\n--- End of Context from: ${path.relative(cwd, projectContextFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdContextFile)} ---\ncwd context content\n--- End of Context from: ${path.relative(cwd, cwdContextFile)} ---`, fileCount: 2, }); }); @@ -163,18 +157,13 @@ cwd context content const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${customFilename} --- -CWD custom memory ---- End of Context from: ${customFilename} --- - ---- Context from: ${path.join('subdir', customFilename)} --- -Subdir custom memory ---- End of Context from: ${path.join('subdir', customFilename)} ---`, + memoryContent: `--- Context from: ${customFilename} ---\nCWD custom memory\n--- End of Context from: ${customFilename} ---\n\n--- Context from: ${path.join('subdir', customFilename)} ---\nSubdir custom memory\n--- End of Context from: ${path.join('subdir', customFilename)} ---`, fileCount: 2, }); }); @@ -191,18 +180,13 @@ Subdir custom memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, projectRootGeminiFile)} --- -Project root memory ---- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} --- - ---- Context from: ${path.relative(cwd, srcGeminiFile)} --- -Src directory memory ---- End of Context from: ${path.relative(cwd, srcGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, srcGeminiFile)} ---\nSrc directory memory\n--- End of Context from: ${path.relative(cwd, srcGeminiFile)} ---`, fileCount: 2, }); }); @@ -219,18 +203,13 @@ Src directory memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} --- -CWD memory ---- End of Context from: ${DEFAULT_CONTEXT_FILENAME} --- - ---- Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} --- -Subdir memory ---- End of Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---`, + memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} ---\nCWD memory\n--- End of Context from: ${DEFAULT_CONTEXT_FILENAME} ---\n\n--- Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---\nSubdir memory\n--- End of Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---`, fileCount: 2, }); }); @@ -259,30 +238,13 @@ Subdir memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} --- -default context content ---- End of Context from: ${path.relative(cwd, defaultContextFile)} --- - ---- Context from: ${path.relative(cwd, rootGeminiFile)} --- -Project parent memory ---- End of Context from: ${path.relative(cwd, rootGeminiFile)} --- - ---- Context from: ${path.relative(cwd, projectRootGeminiFile)} --- -Project root memory ---- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} --- - ---- Context from: ${path.relative(cwd, cwdGeminiFile)} --- -CWD memory ---- End of Context from: ${path.relative(cwd, cwdGeminiFile)} --- - ---- Context from: ${path.relative(cwd, subDirGeminiFile)} --- -Subdir memory ---- End of Context from: ${path.relative(cwd, subDirGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootGeminiFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdGeminiFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, subDirGeminiFile)} ---\nSubdir memory\n--- End of Context from: ${path.relative(cwd, subDirGeminiFile)} ---`, fileCount: 5, }); }); @@ -302,6 +264,7 @@ Subdir memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), [], @@ -314,9 +277,7 @@ Subdir memory ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, regularSubDirGeminiFile)} --- -My code memory ---- End of Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---\nMy code memory\n--- End of Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---`, fileCount: 1, }); }); @@ -333,6 +294,7 @@ My code memory // Pass the custom limit directly to the function await loadServerHierarchicalMemory( cwd, + [], true, new FileDiscoveryService(projectRoot), [], @@ -353,6 +315,7 @@ My code memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); @@ -371,15 +334,36 @@ My code memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), [extensionFilePath], ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, extensionFilePath)} --- -Extension memory content ---- End of Context from: ${path.relative(cwd, extensionFilePath)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, extensionFilePath)} ---\nExtension memory content\n--- End of Context from: ${path.relative(cwd, extensionFilePath)} ---`, + fileCount: 1, + }); + }); + + it('should load memory from included directories', async () => { + const includedDir = await createEmptyDir( + path.join(testRootDir, 'included'), + ); + const includedFile = await createTestFile( + path.join(includedDir, DEFAULT_CONTEXT_FILENAME), + 'included directory memory', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [includedDir], + false, + new FileDiscoveryService(projectRoot), + ); + + expect(result).toEqual({ + memoryContent: `--- Context from: ${path.relative(cwd, includedFile)} ---\nincluded directory memory\n--- End of Context from: ${path.relative(cwd, includedFile)} ---`, fileCount: 1, }); }); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 323b13c5..f53d27a9 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -83,6 +83,36 @@ async function findProjectRoot(startDir: string): Promise { async function getGeminiMdFilePathsInternal( currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[], + userHomePath: string, + debugMode: boolean, + fileService: FileDiscoveryService, + extensionContextFilePaths: string[] = [], + fileFilteringOptions: FileFilteringOptions, + maxDirs: number, +): Promise { + const dirs = new Set([ + ...includeDirectoriesToReadGemini, + currentWorkingDirectory, + ]); + const paths = []; + for (const dir of dirs) { + const pathsByDir = await getGeminiMdFilePathsInternalForEachDir( + dir, + userHomePath, + debugMode, + fileService, + extensionContextFilePaths, + fileFilteringOptions, + maxDirs, + ); + paths.push(...pathsByDir); + } + return Array.from(new Set(paths)); +} + +async function getGeminiMdFilePathsInternalForEachDir( + dir: string, userHomePath: string, debugMode: boolean, fileService: FileDiscoveryService, @@ -115,8 +145,8 @@ async function getGeminiMdFilePathsInternal( // FIX: Only perform the workspace search (upward and downward scans) // if a valid currentWorkingDirectory is provided. - if (currentWorkingDirectory) { - const resolvedCwd = path.resolve(currentWorkingDirectory); + if (dir) { + const resolvedCwd = path.resolve(dir); if (debugMode) logger.debug( `Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`, @@ -257,6 +287,7 @@ function concatenateInstructions( */ export async function loadServerHierarchicalMemory( currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[], debugMode: boolean, fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], @@ -274,6 +305,7 @@ export async function loadServerHierarchicalMemory( const userHomePath = homedir(); const filePaths = await getGeminiMdFilePathsInternal( currentWorkingDirectory, + includeDirectoriesToReadGemini, userHomePath, debugMode, fileService, @@ -282,7 +314,8 @@ export async function loadServerHierarchicalMemory( maxDirs, ); 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 of the workspace.'); return { memoryContent: '', fileCount: 0 }; } const contentsWithPaths = await readGeminiMdFiles( diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 16d1b4c9..efbc8a4c 100644 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -15,6 +15,8 @@ import * as path from 'path'; export class WorkspaceContext { private directories: Set; + private initialDirectories: Set; + /** * Creates a new WorkspaceContext with the given initial directory and optional additional directories. * @param initialDirectory The initial working directory (usually cwd) @@ -22,11 +24,14 @@ export class WorkspaceContext { */ constructor(initialDirectory: string, additionalDirectories: string[] = []) { this.directories = new Set(); + this.initialDirectories = new Set(); this.addDirectoryInternal(initialDirectory); + this.addInitialDirectoryInternal(initialDirectory); for (const dir of additionalDirectories) { this.addDirectoryInternal(dir); + this.addInitialDirectoryInternal(dir); } } @@ -69,6 +74,33 @@ export class WorkspaceContext { this.directories.add(realPath); } + private addInitialDirectoryInternal( + directory: string, + basePath: string = process.cwd(), + ): void { + const absolutePath = path.isAbsolute(directory) + ? directory + : path.resolve(basePath, directory); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`Directory does not exist: ${absolutePath}`); + } + + const stats = fs.statSync(absolutePath); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${absolutePath}`); + } + + let realPath: string; + try { + realPath = fs.realpathSync(absolutePath); + } catch (_error) { + throw new Error(`Failed to resolve path: ${absolutePath}`); + } + + this.initialDirectories.add(realPath); + } + /** * Gets a copy of all workspace directories. * @returns Array of absolute directory paths @@ -77,6 +109,17 @@ export class WorkspaceContext { return Array.from(this.directories); } + getInitialDirectories(): readonly string[] { + return Array.from(this.initialDirectories); + } + + setDirectories(directories: readonly string[]): void { + this.directories.clear(); + for (const dir of directories) { + this.addDirectoryInternal(dir); + } + } + /** * Checks if a given path is within any of the workspace directories. * @param pathToCheck The path to validate