mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-15 12:30:32 -07:00
chore: clean up launched memory features
This commit is contained in:
@@ -90,6 +90,7 @@ describe('getEnvironmentContext', () => {
|
||||
getFileService: vi.fn(),
|
||||
getIncludeDirectoryTree: vi.fn().mockReturnValue(true),
|
||||
getEnvironmentMemory: vi.fn().mockReturnValue('Mock Environment Memory'),
|
||||
getSessionMemory: vi.fn().mockReturnValue('Mock Session Memory'),
|
||||
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
storage: {
|
||||
@@ -116,7 +117,7 @@ describe('getEnvironmentContext', () => {
|
||||
expect(context).toContain(
|
||||
'- **Directory Structure:**\n\nMock Folder Structure',
|
||||
);
|
||||
expect(context).toContain('Mock Environment Memory');
|
||||
expect(context).toContain('Mock Session Memory');
|
||||
expect(context).toContain('</session_context>');
|
||||
expect(getFolderStructure).toHaveBeenCalledWith('/test/dir', {
|
||||
fileService: undefined,
|
||||
@@ -160,15 +161,12 @@ describe('getEnvironmentContext', () => {
|
||||
expect(context).toContain('<session_context>');
|
||||
expect(context).not.toContain('Directory Structure:');
|
||||
expect(context).not.toContain('Mock Folder Structure');
|
||||
expect(context).toContain('Mock Environment Memory');
|
||||
expect(context).toContain('Mock Session Memory');
|
||||
expect(context).toContain('</session_context>');
|
||||
expect(getFolderStructure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use session memory instead of environment memory when JIT context is enabled', async () => {
|
||||
(mockConfig as Record<string, unknown>)['isJitContextEnabled'] = vi
|
||||
.fn()
|
||||
.mockReturnValue(true);
|
||||
it('should use session memory instead of environment memory', async () => {
|
||||
(mockConfig as Record<string, unknown>)['getSessionMemory'] = vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
@@ -188,17 +186,6 @@ describe('getEnvironmentContext', () => {
|
||||
expect(context).toContain('</loaded_context>');
|
||||
});
|
||||
|
||||
it('should include environment memory when JIT context is disabled', async () => {
|
||||
(mockConfig as Record<string, unknown>)['isJitContextEnabled'] = vi
|
||||
.fn()
|
||||
.mockReturnValue(false);
|
||||
|
||||
const parts = await getEnvironmentContext(mockConfig as Config);
|
||||
|
||||
const context = parts[0].text;
|
||||
expect(context).toContain('Mock Environment Memory');
|
||||
});
|
||||
|
||||
it('should handle read_many_files returning no content', async () => {
|
||||
const mockReadManyFilesTool = {
|
||||
build: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -61,12 +61,7 @@ export async function getEnvironmentContext(config: Config): Promise<Part[]> {
|
||||
// - Tier 1 (global): system instruction only
|
||||
// - Tier 2 (extension + project): first user message (here)
|
||||
// - Tier 3 (subdirectory): tool output (JIT)
|
||||
// When JIT is enabled, Tier 2 memory is provided by getSessionMemory().
|
||||
// When JIT is disabled, all memory is in the system instruction and
|
||||
// getEnvironmentMemory() provides the project memory for this message.
|
||||
const environmentMemory = config.isJitContextEnabled?.()
|
||||
? config.getSessionMemory()
|
||||
: config.getEnvironmentMemory();
|
||||
const environmentMemory = config.getSessionMemory();
|
||||
|
||||
const context = `
|
||||
<session_context>
|
||||
|
||||
@@ -19,16 +19,6 @@ import type { Config, GeminiCLIExtension } from '../config/config.js';
|
||||
import { type McpClientManager } from '../tools/mcp-client-manager.js';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
|
||||
const mockRefreshServerHierarchicalMemory = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./memoryDiscovery.js', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./memoryDiscovery.js')>();
|
||||
return {
|
||||
...actual,
|
||||
refreshServerHierarchicalMemory: mockRefreshServerHierarchicalMemory,
|
||||
};
|
||||
});
|
||||
|
||||
describe('SimpleExtensionLoader', () => {
|
||||
let mockConfig: Config;
|
||||
let extensionReloadingEnabled: boolean;
|
||||
@@ -36,6 +26,8 @@ describe('SimpleExtensionLoader', () => {
|
||||
let mockGeminiClientSetTools: MockInstance<
|
||||
typeof GeminiClient.prototype.setTools
|
||||
>;
|
||||
let mockGeminiClientUpdateSystemInstruction: MockInstance;
|
||||
let mockMemoryRefresh: MockInstance;
|
||||
let mockHookSystemInit: MockInstance;
|
||||
let mockAgentRegistryReload: MockInstance;
|
||||
let mockSkillsReload: MockInstance;
|
||||
@@ -86,6 +78,8 @@ describe('SimpleExtensionLoader', () => {
|
||||
} as unknown as McpClientManager;
|
||||
extensionReloadingEnabled = false;
|
||||
mockGeminiClientSetTools = vi.fn();
|
||||
mockGeminiClientUpdateSystemInstruction = vi.fn();
|
||||
mockMemoryRefresh = vi.fn();
|
||||
mockHookSystemInit = vi.fn();
|
||||
mockAgentRegistryReload = vi.fn();
|
||||
mockSkillsReload = vi.fn();
|
||||
@@ -101,10 +95,15 @@ describe('SimpleExtensionLoader', () => {
|
||||
geminiClient: {
|
||||
isInitialized: () => true,
|
||||
setTools: mockGeminiClientSetTools,
|
||||
updateSystemInstruction: mockGeminiClientUpdateSystemInstruction,
|
||||
},
|
||||
getGeminiClient: vi.fn(() => ({
|
||||
isInitialized: () => true,
|
||||
setTools: mockGeminiClientSetTools,
|
||||
updateSystemInstruction: mockGeminiClientUpdateSystemInstruction,
|
||||
})),
|
||||
getMemoryContextManager: vi.fn(() => ({
|
||||
refresh: mockMemoryRefresh,
|
||||
})),
|
||||
getHookSystem: () => ({
|
||||
initialize: mockHookSystemInit,
|
||||
@@ -193,20 +192,27 @@ describe('SimpleExtensionLoader', () => {
|
||||
expect(
|
||||
mockMcpClientManager.startExtension,
|
||||
).toHaveBeenCalledExactlyOnceWith(activeExtension);
|
||||
expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce();
|
||||
expect(mockMemoryRefresh).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
mockGeminiClientUpdateSystemInstruction,
|
||||
).toHaveBeenCalledOnce();
|
||||
expect(mockHookSystemInit).toHaveBeenCalledOnce();
|
||||
expect(mockGeminiClientSetTools).toHaveBeenCalledOnce();
|
||||
expect(mockAgentRegistryReload).toHaveBeenCalledOnce();
|
||||
expect(mockSkillsReload).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
expect(mockMcpClientManager.startExtension).not.toHaveBeenCalled();
|
||||
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
expect(mockMemoryRefresh).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockGeminiClientUpdateSystemInstruction,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(mockHookSystemInit).not.toHaveBeenCalled();
|
||||
expect(mockGeminiClientSetTools).not.toHaveBeenCalledOnce();
|
||||
expect(mockAgentRegistryReload).not.toHaveBeenCalled();
|
||||
expect(mockSkillsReload).not.toHaveBeenCalled();
|
||||
}
|
||||
mockRefreshServerHierarchicalMemory.mockClear();
|
||||
mockMemoryRefresh.mockClear();
|
||||
mockGeminiClientUpdateSystemInstruction.mockClear();
|
||||
mockHookSystemInit.mockClear();
|
||||
mockGeminiClientSetTools.mockClear();
|
||||
mockAgentRegistryReload.mockClear();
|
||||
@@ -217,14 +223,20 @@ describe('SimpleExtensionLoader', () => {
|
||||
expect(
|
||||
mockMcpClientManager.stopExtension,
|
||||
).toHaveBeenCalledExactlyOnceWith(activeExtension);
|
||||
expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce();
|
||||
expect(mockMemoryRefresh).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
mockGeminiClientUpdateSystemInstruction,
|
||||
).toHaveBeenCalledOnce();
|
||||
expect(mockHookSystemInit).toHaveBeenCalledOnce();
|
||||
expect(mockGeminiClientSetTools).toHaveBeenCalledOnce();
|
||||
expect(mockAgentRegistryReload).toHaveBeenCalledOnce();
|
||||
expect(mockSkillsReload).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
expect(mockMcpClientManager.stopExtension).not.toHaveBeenCalled();
|
||||
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
expect(mockMemoryRefresh).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockGeminiClientUpdateSystemInstruction,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(mockHookSystemInit).not.toHaveBeenCalled();
|
||||
expect(mockGeminiClientSetTools).not.toHaveBeenCalledOnce();
|
||||
expect(mockAgentRegistryReload).not.toHaveBeenCalled();
|
||||
@@ -242,12 +254,15 @@ describe('SimpleExtensionLoader', () => {
|
||||
const loader = new SimpleExtensionLoader([]);
|
||||
await loader.loadExtension(activeExtension);
|
||||
await loader.start(mockConfig);
|
||||
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
expect(mockMemoryRefresh).not.toHaveBeenCalled();
|
||||
await Promise.all([
|
||||
loader.unloadExtension(activeExtension),
|
||||
loader.loadExtension(anotherExtension),
|
||||
]);
|
||||
expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce();
|
||||
expect(mockMemoryRefresh).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
mockGeminiClientUpdateSystemInstruction,
|
||||
).toHaveBeenCalledOnce();
|
||||
expect(mockHookSystemInit).toHaveBeenCalledOnce();
|
||||
expect(mockAgentRegistryReload).toHaveBeenCalledOnce();
|
||||
expect(mockSkillsReload).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import type { Config, GeminiCLIExtension } from '../config/config.js';
|
||||
import { refreshServerHierarchicalMemory } from './memoryDiscovery.js';
|
||||
|
||||
export abstract class ExtensionLoader {
|
||||
// Assigned in `start`.
|
||||
@@ -125,7 +124,8 @@ export abstract class ExtensionLoader {
|
||||
// Wait until all extensions are done starting and stopping before we
|
||||
// reload memory, this is somewhat expensive and also busts the context
|
||||
// cache, we want to only do it once.
|
||||
await refreshServerHierarchicalMemory(this.config);
|
||||
await this.config.getMemoryContextManager()?.refresh();
|
||||
this.config.getGeminiClient().updateSystemInstruction();
|
||||
await this.config.getHookSystem()?.initialize();
|
||||
await this.config.getAgentRegistry().reload();
|
||||
await this.config.reloadSkills();
|
||||
|
||||
@@ -9,13 +9,12 @@ import * as fsPromises from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
loadServerHierarchicalMemory,
|
||||
deduplicatePathsByFileIdentity,
|
||||
getGlobalMemoryPaths,
|
||||
getExtensionMemoryPaths,
|
||||
getEnvironmentMemoryPaths,
|
||||
getUserProjectMemoryPaths,
|
||||
loadJitSubdirectoryMemory,
|
||||
refreshServerHierarchicalMemory,
|
||||
readGeminiMdFiles,
|
||||
} from './memoryDiscovery.js';
|
||||
import {
|
||||
@@ -23,29 +22,13 @@ import {
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
PROJECT_MEMORY_INDEX_FILENAME,
|
||||
} from '../tools/memoryTool.js';
|
||||
import { flattenMemory, type HierarchicalMemory } from '../config/memory.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
toAbsolutePath,
|
||||
homedir as pathsHomedir,
|
||||
} from './paths.js';
|
||||
|
||||
function flattenResult(result: {
|
||||
memoryContent: HierarchicalMemory;
|
||||
fileCount: number;
|
||||
filePaths: string[];
|
||||
}) {
|
||||
return {
|
||||
...result,
|
||||
memoryContent: flattenMemory(result.memoryContent),
|
||||
filePaths: result.filePaths,
|
||||
};
|
||||
}
|
||||
import { Config, type GeminiCLIExtension } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import type { GeminiCLIExtension } from '../config/config.js';
|
||||
import { SimpleExtensionLoader } from './extensionLoader.js';
|
||||
import { CoreEvent, coreEvents } from './events.js';
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof os>();
|
||||
@@ -68,9 +51,7 @@ vi.mock('../utils/paths.js', async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe('memoryDiscovery', () => {
|
||||
const DEFAULT_FOLDER_TRUST = true;
|
||||
let testRootDir: string;
|
||||
let cwd: string;
|
||||
let projectRoot: string;
|
||||
let homedir: string;
|
||||
|
||||
@@ -98,7 +79,6 @@ describe('memoryDiscovery', () => {
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
projectRoot = await createEmptyDir(path.join(testRootDir, 'project'));
|
||||
cwd = await createEmptyDir(path.join(projectRoot, 'src'));
|
||||
homedir = await createEmptyDir(path.join(testRootDir, 'userhome'));
|
||||
vi.mocked(os.homedir).mockReturnValue(homedir);
|
||||
vi.mocked(pathsHomedir).mockReturnValue(homedir);
|
||||
@@ -118,578 +98,10 @@ describe('memoryDiscovery', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when untrusted', () => {
|
||||
it('does not load context files from untrusted workspaces', async () => {
|
||||
await createTestFile(
|
||||
path.join(projectRoot, DEFAULT_CONTEXT_FILENAME),
|
||||
'Project root memory',
|
||||
);
|
||||
await createTestFile(
|
||||
path.join(cwd, DEFAULT_CONTEXT_FILENAME),
|
||||
'Src directory memory',
|
||||
);
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
false, // untrusted
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('loads context from outside the untrusted workspace', async () => {
|
||||
await createTestFile(
|
||||
path.join(projectRoot, DEFAULT_CONTEXT_FILENAME),
|
||||
'Project root memory', // Untrusted
|
||||
);
|
||||
await createTestFile(
|
||||
path.join(cwd, DEFAULT_CONTEXT_FILENAME),
|
||||
'Src directory memory', // Untrusted
|
||||
);
|
||||
|
||||
const filepathInput = path.join(
|
||||
homedir,
|
||||
GEMINI_DIR,
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
);
|
||||
const filepath = await createTestFile(
|
||||
filepathInput,
|
||||
'default context content',
|
||||
); // In user home dir (outside untrusted space).
|
||||
const { fileCount, memoryContent, filePaths } = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
false, // untrusted
|
||||
),
|
||||
);
|
||||
|
||||
expect(fileCount).toEqual(1);
|
||||
expect(memoryContent).toContain(filepath);
|
||||
expect(filePaths).toEqual([filepath]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty memory and count if no context files are found', async () => {
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should load only the global context file if present and others are not (default filename)', async () => {
|
||||
const defaultContextFile = await createTestFile(
|
||||
path.join(homedir, GEMINI_DIR, DEFAULT_CONTEXT_FILENAME),
|
||||
'default context content',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect({
|
||||
...result,
|
||||
memoryContent: flattenMemory(result.memoryContent),
|
||||
}).toEqual({
|
||||
memoryContent: `--- Global ---
|
||||
--- Context from: ${defaultContextFile} ---
|
||||
default context content
|
||||
--- End of Context from: ${defaultContextFile} ---`,
|
||||
fileCount: 1,
|
||||
filePaths: [defaultContextFile],
|
||||
});
|
||||
});
|
||||
|
||||
it('should load only the global custom context file if present and filename is changed', async () => {
|
||||
const customFilename = 'CUSTOM_AGENTS.md';
|
||||
setGeminiMdFilename(customFilename);
|
||||
|
||||
const customContextFile = await createTestFile(
|
||||
path.join(homedir, GEMINI_DIR, customFilename),
|
||||
'custom context content',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Global ---
|
||||
--- Context from: ${customContextFile} ---
|
||||
custom context content
|
||||
--- End of Context from: ${customContextFile} ---`,
|
||||
fileCount: 1,
|
||||
filePaths: [customContextFile],
|
||||
});
|
||||
});
|
||||
|
||||
it('should load context files by upward traversal with custom filename', async () => {
|
||||
const customFilename = 'PROJECT_CONTEXT.md';
|
||||
setGeminiMdFilename(customFilename);
|
||||
|
||||
const projectContextFile = await createTestFile(
|
||||
path.join(projectRoot, customFilename),
|
||||
'project context content',
|
||||
);
|
||||
const cwdContextFile = await createTestFile(
|
||||
path.join(cwd, customFilename),
|
||||
'cwd context content',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Project ---
|
||||
--- Context from: ${projectContextFile} ---
|
||||
project context content
|
||||
--- End of Context from: ${projectContextFile} ---
|
||||
|
||||
--- Context from: ${cwdContextFile} ---
|
||||
cwd context content
|
||||
--- End of Context from: ${cwdContextFile} ---`,
|
||||
fileCount: 2,
|
||||
filePaths: [projectContextFile, cwdContextFile],
|
||||
});
|
||||
});
|
||||
|
||||
it('should load context files by downward traversal with custom filename', async () => {
|
||||
const customFilename = 'LOCAL_CONTEXT.md';
|
||||
setGeminiMdFilename(customFilename);
|
||||
|
||||
const subdirCustomFile = await createTestFile(
|
||||
path.join(cwd, 'subdir', customFilename),
|
||||
'Subdir custom memory',
|
||||
);
|
||||
const cwdCustomFile = await createTestFile(
|
||||
path.join(cwd, customFilename),
|
||||
'CWD custom memory',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Project ---
|
||||
--- Context from: ${cwdCustomFile} ---
|
||||
CWD custom memory
|
||||
--- End of Context from: ${cwdCustomFile} ---
|
||||
|
||||
--- Context from: ${subdirCustomFile} ---
|
||||
Subdir custom memory
|
||||
--- End of Context from: ${subdirCustomFile} ---`,
|
||||
fileCount: 2,
|
||||
filePaths: [cwdCustomFile, subdirCustomFile],
|
||||
});
|
||||
});
|
||||
|
||||
it('should load ORIGINAL_GEMINI_MD_FILENAME files by upward traversal from CWD to project root', async () => {
|
||||
const projectRootGeminiFile = await createTestFile(
|
||||
path.join(projectRoot, DEFAULT_CONTEXT_FILENAME),
|
||||
'Project root memory',
|
||||
);
|
||||
const srcGeminiFile = await createTestFile(
|
||||
path.join(cwd, DEFAULT_CONTEXT_FILENAME),
|
||||
'Src directory memory',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Project ---
|
||||
--- Context from: ${projectRootGeminiFile} ---
|
||||
Project root memory
|
||||
--- End of Context from: ${projectRootGeminiFile} ---
|
||||
|
||||
--- Context from: ${srcGeminiFile} ---
|
||||
Src directory memory
|
||||
--- End of Context from: ${srcGeminiFile} ---`,
|
||||
fileCount: 2,
|
||||
filePaths: [projectRootGeminiFile, srcGeminiFile],
|
||||
});
|
||||
});
|
||||
|
||||
it('should load ORIGINAL_GEMINI_MD_FILENAME files by downward traversal from CWD', async () => {
|
||||
const subDirGeminiFile = await createTestFile(
|
||||
path.join(cwd, 'subdir', DEFAULT_CONTEXT_FILENAME),
|
||||
'Subdir memory',
|
||||
);
|
||||
const cwdGeminiFile = await createTestFile(
|
||||
path.join(cwd, DEFAULT_CONTEXT_FILENAME),
|
||||
'CWD memory',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Project ---
|
||||
--- Context from: ${cwdGeminiFile} ---
|
||||
CWD memory
|
||||
--- End of Context from: ${cwdGeminiFile} ---
|
||||
|
||||
--- Context from: ${subDirGeminiFile} ---
|
||||
Subdir memory
|
||||
--- End of Context from: ${subDirGeminiFile} ---`,
|
||||
fileCount: 2,
|
||||
filePaths: [cwdGeminiFile, subDirGeminiFile],
|
||||
});
|
||||
});
|
||||
|
||||
it('should load and correctly order global, upward, and downward ORIGINAL_GEMINI_MD_FILENAME files', async () => {
|
||||
const defaultContextFile = await createTestFile(
|
||||
path.join(homedir, GEMINI_DIR, DEFAULT_CONTEXT_FILENAME),
|
||||
'default context content',
|
||||
);
|
||||
const rootGeminiFile = await createTestFile(
|
||||
path.join(testRootDir, DEFAULT_CONTEXT_FILENAME),
|
||||
'Project parent memory',
|
||||
);
|
||||
const projectRootGeminiFile = await createTestFile(
|
||||
path.join(projectRoot, DEFAULT_CONTEXT_FILENAME),
|
||||
'Project root memory',
|
||||
);
|
||||
const cwdGeminiFile = await createTestFile(
|
||||
path.join(cwd, DEFAULT_CONTEXT_FILENAME),
|
||||
'CWD memory',
|
||||
);
|
||||
const subDirGeminiFile = await createTestFile(
|
||||
path.join(cwd, 'sub', DEFAULT_CONTEXT_FILENAME),
|
||||
'Subdir memory',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Global ---
|
||||
--- Context from: ${defaultContextFile} ---
|
||||
default context content
|
||||
--- End of Context from: ${defaultContextFile} ---
|
||||
|
||||
--- Project ---
|
||||
--- Context from: ${rootGeminiFile} ---
|
||||
Project parent memory
|
||||
--- End of Context from: ${rootGeminiFile} ---
|
||||
|
||||
--- Context from: ${projectRootGeminiFile} ---
|
||||
Project root memory
|
||||
--- End of Context from: ${projectRootGeminiFile} ---
|
||||
|
||||
--- Context from: ${cwdGeminiFile} ---
|
||||
CWD memory
|
||||
--- End of Context from: ${cwdGeminiFile} ---
|
||||
|
||||
--- Context from: ${subDirGeminiFile} ---
|
||||
Subdir memory
|
||||
--- End of Context from: ${subDirGeminiFile} ---`,
|
||||
fileCount: 5,
|
||||
filePaths: [
|
||||
defaultContextFile,
|
||||
rootGeminiFile,
|
||||
projectRootGeminiFile,
|
||||
cwdGeminiFile,
|
||||
subDirGeminiFile,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore specified directories during downward scan', async () => {
|
||||
await createEmptyDir(path.join(projectRoot, '.git'));
|
||||
await createTestFile(path.join(projectRoot, '.gitignore'), 'node_modules');
|
||||
|
||||
await createTestFile(
|
||||
path.join(cwd, 'node_modules', DEFAULT_CONTEXT_FILENAME),
|
||||
'Ignored memory',
|
||||
);
|
||||
const regularSubDirGeminiFile = await createTestFile(
|
||||
path.join(cwd, 'my_code', DEFAULT_CONTEXT_FILENAME),
|
||||
'My code memory',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
'tree',
|
||||
{
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
},
|
||||
200, // maxDirs parameter
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Project ---
|
||||
--- Context from: ${regularSubDirGeminiFile} ---
|
||||
My code memory
|
||||
--- End of Context from: ${regularSubDirGeminiFile} ---`,
|
||||
fileCount: 1,
|
||||
filePaths: [regularSubDirGeminiFile],
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect the maxDirs parameter during downward scan', async () => {
|
||||
// Create directories in parallel for better performance
|
||||
const dirPromises = Array.from({ length: 2 }, (_, i) =>
|
||||
createEmptyDir(path.join(cwd, `deep_dir_${i}`)),
|
||||
);
|
||||
await Promise.all(dirPromises);
|
||||
|
||||
// Pass the custom limit directly to the function
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
'tree', // importFormat
|
||||
{
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
},
|
||||
1, // maxDirs
|
||||
);
|
||||
|
||||
// Note: bfsFileSearch debug logging is no longer controlled via debugMode parameter
|
||||
// The test verifies maxDirs is respected by checking the result, not debug logs
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should load extension context file paths', async () => {
|
||||
const extensionFilePath = await createTestFile(
|
||||
path.join(testRootDir, 'extensions/ext1/GEMINI.md'),
|
||||
'Extension memory content',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([
|
||||
{
|
||||
contextFiles: [extensionFilePath],
|
||||
isActive: true,
|
||||
} as GeminiCLIExtension,
|
||||
]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Extension ---
|
||||
--- Context from: ${extensionFilePath} ---
|
||||
Extension memory content
|
||||
--- End of Context from: ${extensionFilePath} ---`,
|
||||
fileCount: 1,
|
||||
filePaths: [extensionFilePath],
|
||||
});
|
||||
});
|
||||
|
||||
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 = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[includedDir],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
memoryContent: `--- Project ---
|
||||
--- Context from: ${includedFile} ---
|
||||
included directory memory
|
||||
--- End of Context from: ${includedFile} ---`,
|
||||
fileCount: 1,
|
||||
filePaths: [includedFile],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple directories and files in parallel correctly', async () => {
|
||||
// Create multiple test directories with GEMINI.md files
|
||||
const numDirs = 5;
|
||||
const createdFiles: string[] = [];
|
||||
|
||||
for (let i = 0; i < numDirs; i++) {
|
||||
const dirPath = await createEmptyDir(
|
||||
path.join(testRootDir, `project-${i}`),
|
||||
);
|
||||
const filePath = await createTestFile(
|
||||
path.join(dirPath, DEFAULT_CONTEXT_FILENAME),
|
||||
`Content from project ${i}`,
|
||||
);
|
||||
createdFiles.push(filePath);
|
||||
}
|
||||
|
||||
// Load memory from all directories
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
createdFiles.map((f) => path.dirname(f)),
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
// Should have loaded all files
|
||||
expect(result.fileCount).toBe(numDirs);
|
||||
expect(result.filePaths.length).toBe(numDirs);
|
||||
expect(result.filePaths.sort()).toEqual(createdFiles.sort());
|
||||
|
||||
// Content should include all project contents
|
||||
const flattenedMemory = flattenMemory(result.memoryContent);
|
||||
for (let i = 0; i < numDirs; i++) {
|
||||
expect(flattenedMemory).toContain(`Content from project ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve order and prevent duplicates when processing multiple directories', async () => {
|
||||
// Create overlapping directory structure
|
||||
const parentDir = await createEmptyDir(path.join(testRootDir, 'parent'));
|
||||
const childDir = await createEmptyDir(path.join(parentDir, 'child'));
|
||||
|
||||
const parentFile = await createTestFile(
|
||||
path.join(parentDir, DEFAULT_CONTEXT_FILENAME),
|
||||
'Parent content',
|
||||
);
|
||||
const childFile = await createTestFile(
|
||||
path.join(childDir, DEFAULT_CONTEXT_FILENAME),
|
||||
'Child content',
|
||||
);
|
||||
|
||||
// Include both parent and child directories
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
parentDir,
|
||||
[childDir, parentDir], // Deliberately include duplicates
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
// Should have both files without duplicates
|
||||
const flattenedMemory = flattenMemory(result.memoryContent);
|
||||
expect(result.fileCount).toBe(2);
|
||||
expect(flattenedMemory).toContain('Parent content');
|
||||
expect(flattenedMemory).toContain('Child content');
|
||||
expect(result.filePaths.sort()).toEqual([parentFile, childFile].sort());
|
||||
|
||||
// Check that files are not duplicated
|
||||
const parentOccurrences = (flattenedMemory.match(/Parent content/g) || [])
|
||||
.length;
|
||||
const childOccurrences = (flattenedMemory.match(/Child content/g) || [])
|
||||
.length;
|
||||
expect(parentOccurrences).toBe(1);
|
||||
expect(childOccurrences).toBe(1);
|
||||
});
|
||||
|
||||
describe('EISDIR handling for GEMINI.md as a directory', () => {
|
||||
it('readGeminiMdFiles returns null content (without throwing) when path is a directory', async () => {
|
||||
const dirAsFilePath = await createEmptyDir(
|
||||
path.join(cwd, DEFAULT_CONTEXT_FILENAME),
|
||||
path.join(projectRoot, DEFAULT_CONTEXT_FILENAME),
|
||||
);
|
||||
|
||||
const results = await readGeminiMdFiles([dirAsFilePath]);
|
||||
@@ -698,83 +110,6 @@ included directory memory
|
||||
expect(results[0].filePath).toBe(dirAsFilePath);
|
||||
expect(results[0].content).toBeNull();
|
||||
});
|
||||
|
||||
it('loadServerHierarchicalMemory ignores a GEMINI.md directory and returns empty memory', async () => {
|
||||
// Create a directory named GEMINI.md where a regular file would be expected.
|
||||
await createEmptyDir(path.join(cwd, DEFAULT_CONTEXT_FILENAME));
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
// EISDIR is silently skipped, so memory is empty (no readable file
|
||||
// contents) and no exception propagates.
|
||||
expect(result.memoryContent).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to a real GEMINI.md file at a higher level when a directory shadows the same name lower in the tree', async () => {
|
||||
// Lower in the tree (cwd): a directory named GEMINI.md (invalid).
|
||||
await createEmptyDir(path.join(cwd, DEFAULT_CONTEXT_FILENAME));
|
||||
// Higher in the tree (projectRoot): a real GEMINI.md file (valid).
|
||||
const projectContextFile = await createTestFile(
|
||||
path.join(projectRoot, DEFAULT_CONTEXT_FILENAME),
|
||||
'Project root memory content',
|
||||
);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
// The directory at cwd is silently skipped; the actual file at
|
||||
// projectRoot is still discovered and loaded normally.
|
||||
expect(result.memoryContent).toContain('Project root memory content');
|
||||
expect(result.filePaths).toContain(projectContextFile);
|
||||
});
|
||||
|
||||
it('silently skips a GEMINI.md symlink that points to a directory', async () => {
|
||||
// Create a real directory elsewhere and symlink GEMINI.md to it.
|
||||
const realDir = await createEmptyDir(path.join(cwd, '.geminimd-target'));
|
||||
const symlinkPath = path.join(cwd, DEFAULT_CONTEXT_FILENAME);
|
||||
try {
|
||||
await fsPromises.symlink(realDir, symlinkPath, 'dir');
|
||||
} catch (err) {
|
||||
// Symlink creation may be unsupported on some Windows setups (no
|
||||
// SeCreateSymbolicLinkPrivilege). Skip the test there rather than fail.
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err as NodeJS.ErrnoException).code === 'EPERM'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
// A symlink resolving to a directory triggers EISDIR on read in the
|
||||
// same way a plain directory does and must be skipped silently.
|
||||
expect(result.memoryContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalMemoryPaths', () => {
|
||||
@@ -1061,7 +396,7 @@ included directory memory
|
||||
});
|
||||
});
|
||||
|
||||
describe('case-insensitive filesystem deduplication', () => {
|
||||
describe('file identity deduplication', () => {
|
||||
it('should deduplicate files that point to the same inode (same physical file)', async () => {
|
||||
const geminiFile = await createTestFile(
|
||||
path.join(projectRoot, 'gemini.md'),
|
||||
@@ -1090,24 +425,16 @@ included directory memory
|
||||
expect(stats1.ino).toBe(stats2.ino);
|
||||
expect(stats1.dev).toBe(stats2.dev);
|
||||
|
||||
setGeminiMdFilename(['GEMINI.md', 'gemini.md']);
|
||||
const result = await deduplicatePathsByFileIdentity([
|
||||
geminiFileLink,
|
||||
geminiFile,
|
||||
]);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
expect(result.paths).toHaveLength(1);
|
||||
expect(result.identityMap.get(geminiFile)).toBe(
|
||||
result.identityMap.get(geminiFileLink),
|
||||
);
|
||||
|
||||
expect(result.fileCount).toBe(1);
|
||||
expect(result.filePaths).toHaveLength(1);
|
||||
expect(result.memoryContent).toContain('Project root memory');
|
||||
const contentMatches = result.memoryContent.match(/Project root memory/g);
|
||||
expect(contentMatches).toHaveLength(1);
|
||||
|
||||
try {
|
||||
await fsPromises.unlink(geminiFileLink);
|
||||
} catch {
|
||||
@@ -1129,45 +456,31 @@ included directory memory
|
||||
const stats2 = await fsPromises.lstat(geminiFileUpper);
|
||||
|
||||
if (stats1.ino !== stats2.ino || stats1.dev !== stats2.dev) {
|
||||
setGeminiMdFilename(['GEMINI.md', 'gemini.md']);
|
||||
const result = await deduplicatePathsByFileIdentity([
|
||||
geminiFileLower,
|
||||
geminiFileUpper,
|
||||
]);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.fileCount).toBe(2);
|
||||
expect(result.filePaths).toHaveLength(2);
|
||||
expect(result.memoryContent).toContain('Lowercase file content');
|
||||
expect(result.memoryContent).toContain('Uppercase file content');
|
||||
expect(result.paths).toHaveLength(2);
|
||||
expect(result.paths).toContain(geminiFileLower);
|
||||
expect(result.paths).toContain(geminiFileUpper);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle files that cannot be stat'd (missing files)", async () => {
|
||||
await createTestFile(
|
||||
const geminiFile = await createTestFile(
|
||||
path.join(projectRoot, 'gemini.md'),
|
||||
'Valid file content',
|
||||
);
|
||||
const missingFile = path.join(projectRoot, 'missing.md');
|
||||
|
||||
setGeminiMdFilename(['gemini.md', 'missing.md']);
|
||||
const result = await deduplicatePathsByFileIdentity([
|
||||
geminiFile,
|
||||
missingFile,
|
||||
]);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.fileCount).toBe(1);
|
||||
expect(result.memoryContent).toContain('Valid file content');
|
||||
expect(result.paths).toEqual([geminiFile, missingFile]);
|
||||
expect(result.identityMap.has(missingFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should deduplicate multiple paths pointing to same file (3+ duplicates)', async () => {
|
||||
@@ -1201,23 +514,19 @@ included directory memory
|
||||
expect(stats1.ino).toBe(stats2.ino);
|
||||
expect(stats1.ino).toBe(stats3.ino);
|
||||
|
||||
setGeminiMdFilename(['gemini.md', 'GEMINI.md', 'Gemini.md']);
|
||||
const result = await deduplicatePathsByFileIdentity([
|
||||
geminiFile,
|
||||
link1,
|
||||
link2,
|
||||
]);
|
||||
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
new FileDiscoveryService(projectRoot),
|
||||
new SimpleExtensionLoader([]),
|
||||
DEFAULT_FOLDER_TRUST,
|
||||
),
|
||||
expect(result.paths).toHaveLength(1);
|
||||
expect(result.identityMap.get(geminiFile)).toBe(
|
||||
result.identityMap.get(link1),
|
||||
);
|
||||
expect(result.identityMap.get(geminiFile)).toBe(
|
||||
result.identityMap.get(link2),
|
||||
);
|
||||
|
||||
expect(result.fileCount).toBe(1);
|
||||
expect(result.filePaths).toHaveLength(1);
|
||||
expect(result.memoryContent).toContain('Project root memory');
|
||||
const contentMatches = result.memoryContent.match(/Project root memory/g);
|
||||
expect(contentMatches).toHaveLength(1);
|
||||
|
||||
try {
|
||||
await fsPromises.unlink(link1);
|
||||
@@ -1554,103 +863,4 @@ included directory memory
|
||||
expect(result.files.find((f) => f.path === subDirMemory)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshServerHierarchicalMemory should refresh memory and update config', async () => {
|
||||
const extensionLoader = new SimpleExtensionLoader([]);
|
||||
const config = new Config({
|
||||
sessionId: '1',
|
||||
targetDir: cwd,
|
||||
cwd,
|
||||
debugMode: false,
|
||||
model: 'fake-model',
|
||||
extensionLoader,
|
||||
});
|
||||
const result = flattenResult(
|
||||
await loadServerHierarchicalMemory(
|
||||
config.getWorkingDir(),
|
||||
config.shouldLoadMemoryFromIncludeDirectories()
|
||||
? config.getWorkspaceContext().getDirectories()
|
||||
: [],
|
||||
config.getFileService(),
|
||||
config.getExtensionLoader(),
|
||||
config.isTrustedFolder(),
|
||||
config.getImportFormat(),
|
||||
),
|
||||
);
|
||||
expect(result.fileCount).equals(0);
|
||||
|
||||
// Now add an extension with a memory file
|
||||
const extensionsDir = new Storage(homedir).getExtensionsDir();
|
||||
const extensionPath = path.join(extensionsDir, 'new-extension');
|
||||
const contextFilePath = path.join(extensionPath, 'CustomContext.md');
|
||||
await fsPromises.mkdir(extensionPath, { recursive: true });
|
||||
await fsPromises.writeFile(contextFilePath, 'Really cool custom context!');
|
||||
await extensionLoader.loadExtension({
|
||||
name: 'new-extension',
|
||||
isActive: true,
|
||||
contextFiles: [contextFilePath],
|
||||
version: '1.0.0',
|
||||
id: '1234',
|
||||
path: extensionPath,
|
||||
});
|
||||
|
||||
const mockEventListener = vi.fn();
|
||||
coreEvents.on(CoreEvent.MemoryChanged, mockEventListener);
|
||||
const refreshResult = await refreshServerHierarchicalMemory(config);
|
||||
expect(refreshResult.fileCount).equals(1);
|
||||
expect(config.getGeminiMdFileCount()).equals(refreshResult.fileCount);
|
||||
const flattenedMemory = flattenMemory(refreshResult.memoryContent);
|
||||
expect(flattenedMemory).toContain('Really cool custom context!');
|
||||
expect(config.getUserMemory()).toStrictEqual(refreshResult.memoryContent);
|
||||
expect(refreshResult.filePaths[0]).toContain(
|
||||
toAbsolutePath(path.join(extensionPath, 'CustomContext.md')),
|
||||
);
|
||||
expect(config.getGeminiMdFilePaths()).equals(refreshResult.filePaths);
|
||||
expect(mockEventListener).toHaveBeenCalledExactlyOnceWith({
|
||||
fileCount: refreshResult.fileCount,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include MCP instructions in user memory', async () => {
|
||||
const mockConfig = {
|
||||
getWorkingDir: vi.fn().mockReturnValue(cwd),
|
||||
shouldLoadMemoryFromIncludeDirectories: vi.fn().mockReturnValue(false),
|
||||
getFileService: vi
|
||||
.fn()
|
||||
.mockReturnValue(new FileDiscoveryService(projectRoot)),
|
||||
getExtensionLoader: vi
|
||||
.fn()
|
||||
.mockReturnValue(new SimpleExtensionLoader([])),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getImportFormat: vi.fn().mockReturnValue('tree'),
|
||||
getFileFilteringOptions: vi.fn().mockReturnValue(undefined),
|
||||
getDiscoveryMaxDirs: vi.fn().mockReturnValue(200),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
setUserMemory: vi.fn(),
|
||||
setGeminiMdFileCount: vi.fn(),
|
||||
setGeminiMdFilePaths: vi.fn(),
|
||||
getMcpClientManager: vi.fn().mockReturnValue({
|
||||
getMcpInstructions: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
"\n\n# Instructions for MCP Server 'extension-server'\nAlways be polite.",
|
||||
),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
await refreshServerHierarchicalMemory(mockConfig);
|
||||
|
||||
expect(mockConfig.setUserMemory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
project: expect.stringContaining(
|
||||
"# Instructions for MCP Server 'extension-server'",
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(mockConfig.setUserMemory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
project: expect.stringContaining('Always be polite.'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,17 +7,11 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { bfsFileSearch } from './bfsFileSearch.js';
|
||||
import {
|
||||
getAllGeminiMdFilenames,
|
||||
PROJECT_MEMORY_INDEX_FILENAME,
|
||||
} from '../tools/memoryTool.js';
|
||||
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { processImports } from './memoryImportProcessor.js';
|
||||
import {
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
type FileFilteringOptions,
|
||||
} from '../config/constants.js';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
@@ -27,9 +21,7 @@ import {
|
||||
} from './paths.js';
|
||||
import type { ExtensionLoader } from './extensionLoader.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { HierarchicalMemory } from '../config/memory.js';
|
||||
import { CoreEvent, coreEvents } from './events.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
|
||||
// Simple console logger, similar to the one previously in CLI's config.ts
|
||||
@@ -214,169 +206,6 @@ async function findProjectRoot(
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeminiMdFilePathsInternal(
|
||||
currentWorkingDirectory: string,
|
||||
includeDirectoriesToReadGemini: readonly string[],
|
||||
userHomePath: string,
|
||||
fileService: FileDiscoveryService,
|
||||
folderTrust: boolean,
|
||||
fileFilteringOptions: FileFilteringOptions,
|
||||
maxDirs: number,
|
||||
boundaryMarkers: readonly string[] = ['.git'],
|
||||
): Promise<{ global: string[]; project: string[] }> {
|
||||
const dirs = new Set<string>([
|
||||
...includeDirectoriesToReadGemini,
|
||||
currentWorkingDirectory,
|
||||
]);
|
||||
|
||||
// Process directories in parallel with concurrency limit to prevent EMFILE errors
|
||||
const CONCURRENT_LIMIT = 10;
|
||||
const dirsArray = Array.from(dirs);
|
||||
const globalPaths = new Set<string>();
|
||||
const projectPaths = new Set<string>();
|
||||
|
||||
for (let i = 0; i < dirsArray.length; i += CONCURRENT_LIMIT) {
|
||||
const batch = dirsArray.slice(i, i + CONCURRENT_LIMIT);
|
||||
const batchPromises = batch.map((dir) =>
|
||||
getGeminiMdFilePathsInternalForEachDir(
|
||||
dir,
|
||||
userHomePath,
|
||||
fileService,
|
||||
folderTrust,
|
||||
fileFilteringOptions,
|
||||
maxDirs,
|
||||
boundaryMarkers,
|
||||
),
|
||||
);
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
for (const result of batchResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
result.value.global.forEach((p) => globalPaths.add(p));
|
||||
result.value.project.forEach((p) => projectPaths.add(p));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const error = result.reason;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Error discovering files in directory: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
global: Array.from(globalPaths),
|
||||
project: Array.from(projectPaths),
|
||||
};
|
||||
}
|
||||
|
||||
async function getGeminiMdFilePathsInternalForEachDir(
|
||||
dir: string,
|
||||
userHomePath: string,
|
||||
fileService: FileDiscoveryService,
|
||||
folderTrust: boolean,
|
||||
fileFilteringOptions: FileFilteringOptions,
|
||||
maxDirs: number,
|
||||
boundaryMarkers: readonly string[] = ['.git'],
|
||||
): Promise<{ global: string[]; project: string[] }> {
|
||||
const globalPaths = new Set<string>();
|
||||
const projectPaths = new Set<string>();
|
||||
const geminiMdFilenames = getAllGeminiMdFilenames();
|
||||
|
||||
for (const geminiMdFilename of geminiMdFilenames) {
|
||||
const resolvedHome = toAbsolutePath(userHomePath);
|
||||
const globalGeminiDir = toAbsolutePath(path.join(resolvedHome, GEMINI_DIR));
|
||||
const globalMemoryPath = toAbsolutePath(
|
||||
path.join(globalGeminiDir, geminiMdFilename),
|
||||
);
|
||||
const globalMemoryKey = normalizePath(globalMemoryPath);
|
||||
const globalGeminiDirKey = normalizePath(globalGeminiDir);
|
||||
|
||||
// This part that finds the global file always runs.
|
||||
try {
|
||||
await fs.access(globalMemoryPath, fsSync.constants.R_OK);
|
||||
globalPaths.add(globalMemoryPath);
|
||||
debugLogger.debug(
|
||||
'[DEBUG] [MemoryDiscovery] Found readable global',
|
||||
geminiMdFilename + ':',
|
||||
globalMemoryPath,
|
||||
);
|
||||
} catch {
|
||||
// It's okay if it's not found.
|
||||
}
|
||||
|
||||
// FIX: Only perform the workspace search (upward and downward scans)
|
||||
// if a valid currentWorkingDirectory is provided.
|
||||
if (dir && folderTrust) {
|
||||
const resolvedCwd = toAbsolutePath(dir);
|
||||
debugLogger.debug(
|
||||
'[DEBUG] [MemoryDiscovery] Searching for',
|
||||
geminiMdFilename,
|
||||
'starting from CWD:',
|
||||
resolvedCwd,
|
||||
);
|
||||
|
||||
const projectRoot = await findProjectRoot(resolvedCwd, boundaryMarkers);
|
||||
debugLogger.debug(
|
||||
'[DEBUG] [MemoryDiscovery] Determined project root:',
|
||||
projectRoot ?? 'None',
|
||||
);
|
||||
|
||||
const upwardPaths: string[] = [];
|
||||
let currentDir = resolvedCwd;
|
||||
const ultimateStopDirKey = projectRoot
|
||||
? normalizePath(path.dirname(projectRoot))
|
||||
: normalizePath(path.dirname(resolvedHome));
|
||||
|
||||
while (currentDir && currentDir !== path.dirname(currentDir)) {
|
||||
if (normalizePath(currentDir) === globalGeminiDirKey) {
|
||||
break;
|
||||
}
|
||||
|
||||
const potentialPath = toAbsolutePath(
|
||||
path.join(currentDir, geminiMdFilename),
|
||||
);
|
||||
try {
|
||||
await fs.access(potentialPath, fsSync.constants.R_OK);
|
||||
if (normalizePath(potentialPath) !== globalMemoryKey) {
|
||||
upwardPaths.unshift(potentialPath);
|
||||
}
|
||||
} catch {
|
||||
// Not found, continue.
|
||||
}
|
||||
|
||||
if (normalizePath(currentDir) === ultimateStopDirKey) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
upwardPaths.forEach((p) => projectPaths.add(p));
|
||||
|
||||
const mergedOptions: FileFilteringOptions = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...fileFilteringOptions,
|
||||
};
|
||||
|
||||
const downwardPaths = await bfsFileSearch(resolvedCwd, {
|
||||
fileName: geminiMdFilename,
|
||||
maxDirs,
|
||||
fileService,
|
||||
fileFilteringOptions: mergedOptions,
|
||||
});
|
||||
downwardPaths.sort();
|
||||
for (const dPath of downwardPaths) {
|
||||
projectPaths.add(toAbsolutePath(dPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
global: Array.from(globalPaths),
|
||||
project: Array.from(projectPaths),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readGeminiMdFiles(
|
||||
filePaths: string[],
|
||||
importFormat: 'flat' | 'tree' = 'tree',
|
||||
@@ -680,158 +509,6 @@ async function findUpwardGeminiFiles(
|
||||
return upwardPaths;
|
||||
}
|
||||
|
||||
export interface LoadServerHierarchicalMemoryResponse {
|
||||
memoryContent: HierarchicalMemory;
|
||||
fileCount: number;
|
||||
filePaths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads hierarchical GEMINI.md files and concatenates their content.
|
||||
* This function is intended for use by the server.
|
||||
*/
|
||||
export async function loadServerHierarchicalMemory(
|
||||
currentWorkingDirectory: string,
|
||||
includeDirectoriesToReadGemini: readonly string[],
|
||||
fileService: FileDiscoveryService,
|
||||
extensionLoader: ExtensionLoader,
|
||||
folderTrust: boolean,
|
||||
importFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
maxDirs: number = 200,
|
||||
boundaryMarkers: readonly string[] = ['.git'],
|
||||
): Promise<LoadServerHierarchicalMemoryResponse> {
|
||||
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
|
||||
const realCwd = normalizePath(
|
||||
await fs.realpath(path.resolve(currentWorkingDirectory)),
|
||||
);
|
||||
const realHome = normalizePath(await fs.realpath(path.resolve(homedir())));
|
||||
const isHomeDirectory = realCwd === realHome;
|
||||
|
||||
// If it is the home directory, pass an empty string to the core memory
|
||||
// function to signal that it should skip the workspace search.
|
||||
currentWorkingDirectory = isHomeDirectory ? '' : currentWorkingDirectory;
|
||||
|
||||
debugLogger.debug(
|
||||
'[DEBUG] [MemoryDiscovery] Loading server hierarchical memory for CWD:',
|
||||
currentWorkingDirectory,
|
||||
`(importFormat: ${importFormat})`,
|
||||
);
|
||||
|
||||
// For the server, homedir() refers to the server process's home.
|
||||
// This is consistent with how MemoryTool already finds the global path.
|
||||
const userHomePath = homedir();
|
||||
|
||||
// 1. SCATTER: Gather all paths
|
||||
const [discoveryResult, extensionPaths] = await Promise.all([
|
||||
getGeminiMdFilePathsInternal(
|
||||
currentWorkingDirectory,
|
||||
includeDirectoriesToReadGemini,
|
||||
userHomePath,
|
||||
fileService,
|
||||
folderTrust,
|
||||
fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
maxDirs,
|
||||
boundaryMarkers,
|
||||
),
|
||||
Promise.resolve(getExtensionMemoryPaths(extensionLoader)),
|
||||
]);
|
||||
|
||||
const allFilePathsStringDeduped = Array.from(
|
||||
new Set([
|
||||
...discoveryResult.global,
|
||||
...discoveryResult.project,
|
||||
...extensionPaths,
|
||||
]),
|
||||
);
|
||||
|
||||
if (allFilePathsStringDeduped.length === 0) {
|
||||
debugLogger.debug(
|
||||
'[DEBUG] [MemoryDiscovery] No GEMINI.md files found in hierarchy of the workspace.',
|
||||
);
|
||||
return {
|
||||
memoryContent: { global: '', extension: '', project: '' },
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
// deduplicate by file identity to handle case-insensitive filesystems
|
||||
const { paths: allFilePaths } = await deduplicatePathsByFileIdentity(
|
||||
allFilePathsStringDeduped,
|
||||
);
|
||||
|
||||
if (allFilePaths.length === 0) {
|
||||
debugLogger.debug(
|
||||
'[DEBUG] [MemoryDiscovery] No unique GEMINI.md files found after deduplication by file identity.',
|
||||
);
|
||||
return {
|
||||
memoryContent: { global: '', extension: '', project: '' },
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 2. GATHER: Read all files in parallel
|
||||
const allContents = await readGeminiMdFiles(
|
||||
allFilePaths,
|
||||
importFormat,
|
||||
boundaryMarkers,
|
||||
);
|
||||
const contentsMap = new Map(allContents.map((c) => [c.filePath, c]));
|
||||
|
||||
// 3. CATEGORIZE: Back into Global, Project, Extension
|
||||
const hierarchicalMemory = categorizeAndConcatenate(
|
||||
{
|
||||
global: discoveryResult.global,
|
||||
extension: extensionPaths,
|
||||
project: discoveryResult.project,
|
||||
},
|
||||
contentsMap,
|
||||
);
|
||||
|
||||
return {
|
||||
memoryContent: hierarchicalMemory,
|
||||
fileCount: allContents.filter((c) => c.content !== null).length,
|
||||
filePaths: allFilePaths,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the hierarchical memory and resets the state of `config` as needed such
|
||||
* that it reflects the new memory.
|
||||
*
|
||||
* Returns the result of the call to `loadHierarchicalGeminiMemory`.
|
||||
*/
|
||||
export async function refreshServerHierarchicalMemory(config: Config) {
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
config.getWorkingDir(),
|
||||
config.shouldLoadMemoryFromIncludeDirectories()
|
||||
? config.getWorkspaceContext().getDirectories()
|
||||
: [],
|
||||
config.getFileService(),
|
||||
config.getExtensionLoader(),
|
||||
config.isTrustedFolder(),
|
||||
config.getImportFormat(),
|
||||
config.getFileFilteringOptions(),
|
||||
config.getDiscoveryMaxDirs(),
|
||||
config.getMemoryBoundaryMarkers(),
|
||||
);
|
||||
const mcpInstructions =
|
||||
config.getMcpClientManager()?.getMcpInstructions() || '';
|
||||
const finalMemory: HierarchicalMemory = {
|
||||
...result.memoryContent,
|
||||
project: [result.memoryContent.project, mcpInstructions.trimStart()]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
};
|
||||
config.setUserMemory(finalMemory);
|
||||
config.setGeminiMdFileCount(result.fileCount);
|
||||
config.setGeminiMdFilePaths(result.filePaths);
|
||||
coreEvents.emit(CoreEvent.MemoryChanged, { fileCount: result.fileCount });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function loadJitSubdirectoryMemory(
|
||||
targetPath: string,
|
||||
trustedRoots: string[],
|
||||
|
||||
Reference in New Issue
Block a user