feat(core): add project-level memory scope to save_memory tool (#24161)

This commit is contained in:
Sandy Tao
2026-03-30 18:32:15 -07:00
committed by GitHub
parent d5733f7b71
commit 0589daf06f
19 changed files with 382 additions and 63 deletions
+98 -3
View File
@@ -19,7 +19,9 @@ import {
getCurrentGeminiMdFilename,
getAllGeminiMdFilenames,
DEFAULT_CONTEXT_FILENAME,
getProjectMemoryFilePath,
} from './memoryTool.js';
import type { Storage } from '../config/storage.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
@@ -113,9 +115,7 @@ describe('MemoryTool', () => {
it('should have correct name, displayName, description, and schema', () => {
expect(memoryTool.name).toBe('save_memory');
expect(memoryTool.displayName).toBe('SaveMemory');
expect(memoryTool.description).toContain(
'Saves concise global user context',
);
expect(memoryTool.description).toContain('Saves concise user context');
expect(memoryTool.schema).toBeDefined();
expect(memoryTool.schema.name).toBe('save_memory');
expect(memoryTool.schema.parametersJsonSchema).toStrictEqual({
@@ -127,6 +127,12 @@ describe('MemoryTool', () => {
description:
'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
},
scope: {
type: 'string',
enum: ['global', 'project'],
description:
"Where to save the memory. 'global' (default) saves to a file loaded in every workspace. 'project' saves to a project-specific file private to the user, not committed to the repo.",
},
},
required: ['fact'],
});
@@ -378,4 +384,93 @@ describe('MemoryTool', () => {
expect(() => memoryTool.build(attackParams)).toThrow();
});
});
describe('project-scope memory', () => {
const mockProjectMemoryDir = path.join(
'/mock',
'.gemini',
'memory',
'test-project',
);
function createMockStorage(): Storage {
return {
getProjectMemoryDir: () => mockProjectMemoryDir,
} as unknown as Storage;
}
it('should reject scope=project when storage is not initialized', () => {
const bus = createMockMessageBus();
const memoryToolNoStorage = new MemoryTool(bus);
const params = { fact: 'project fact', scope: 'project' as const };
expect(memoryToolNoStorage.validateToolParams(params)).toBe(
'Project-level memory is not available: storage is not initialized.',
);
});
it('should write to global path when scope is not specified', async () => {
const bus = createMockMessageBus();
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
const memoryToolWithStorage = new MemoryTool(bus, createMockStorage());
const params = { fact: 'global fact' };
const invocation = memoryToolWithStorage.build(params);
await invocation.execute(mockAbortSignal);
const expectedFilePath = path.join(
os.homedir(),
GEMINI_DIR,
getCurrentGeminiMdFilename(),
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedFilePath,
expect.any(String),
'utf-8',
);
});
it('should write to project memory path when scope is project', async () => {
const bus = createMockMessageBus();
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
const memoryToolWithStorage = new MemoryTool(bus, createMockStorage());
const params = {
fact: 'project-specific fact',
scope: 'project' as const,
};
const invocation = memoryToolWithStorage.build(params);
await invocation.execute(mockAbortSignal);
const expectedFilePath = path.join(
mockProjectMemoryDir,
getCurrentGeminiMdFilename(),
);
expect(fs.mkdir).toHaveBeenCalledWith(mockProjectMemoryDir, {
recursive: true,
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedFilePath,
expect.stringContaining('- project-specific fact'),
'utf-8',
);
});
it('should use project path in confirmation details when scope is project', async () => {
const bus = createMockMessageBus();
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
const memoryToolWithStorage = new MemoryTool(bus, createMockStorage());
const params = { fact: 'project fact', scope: 'project' as const };
const invocation = memoryToolWithStorage.build(params);
const result = await invocation.shouldConfirmExecute(mockAbortSignal);
expect(result).toBeDefined();
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
expect(result.fileName).toBe(
getProjectMemoryFilePath(createMockStorage()),
);
expect(result.newContent).toContain('- project fact');
}
});
});
});