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
@@ -640,13 +640,13 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snapshot for tool: save_memory 1`] = `
{
"description": "
Saves concise global user context (preferences, facts) for use across ALL workspaces.
Saves concise user context (preferences, facts) for use across future sessions.
### CRITICAL: GLOBAL CONTEXT ONLY
NEVER save workspace-specific context, local paths, or commands (e.g. "The entry point is src/index.js", "The test command is npm test"). These are local to the current workspace and must NOT be saved globally. EXCLUSIVELY for context relevant across ALL workspaces.
Supports two scopes:
- **global** (default): Cross-project preferences loaded in every workspace. Use for "Remember X" or clear personal facts.
- **project**: Facts specific to the current workspace, private to the user (not committed to the repo). Use for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
- Use for "Remember X" or clear personal facts.
- Do NOT use for session context.",
Do NOT use for session-specific context or temporary data.",
"name": "save_memory",
"parametersJsonSchema": {
"additionalProperties": false,
@@ -655,6 +655,14 @@ NEVER save workspace-specific context, local paths, or commands (e.g. "The entry
"description": "The specific fact or piece of information to remember. Should be a clear, self-contained statement.",
"type": "string",
},
"scope": {
"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.",
"enum": [
"global",
"project",
],
"type": "string",
},
},
"required": [
"fact",
@@ -1433,13 +1441,21 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: save_memory 1`] = `
{
"description": "Persists global preferences or facts across ALL future sessions. Use this for recurring instructions like coding styles or tool aliases. Unlike 'write_file', which is for project-specific files, this appends to a global memory file loaded in every workspace. If you are unsure whether a fact should be remembered globally, ask the user first. CRITICAL: Do not use for session-specific context or temporary data.",
"description": "Persists preferences or facts across ALL future sessions. Supports two scopes: 'global' (default) for cross-project preferences loaded in every workspace, and 'project' for facts specific to the current workspace that are private to the user (not committed to the repo). Use 'project' scope for things like local dev setup notes, project-specific workflows, or personal reminders about this codebase. CRITICAL: Do not use for session-specific context or temporary data.",
"name": "save_memory",
"parametersJsonSchema": {
"additionalProperties": false,
"properties": {
"fact": {
"description": "A concise, global fact or preference (e.g., 'I prefer using tabs'). Do not include local paths or project-specific names.",
"description": "A concise fact or preference to remember. Should be a clear, self-contained statement.",
"type": "string",
},
"scope": {
"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.",
"enum": [
"global",
"project",
],
"type": "string",
},
},
@@ -92,6 +92,7 @@ export const READ_MANY_PARAM_USE_DEFAULT_EXCLUDES = 'useDefaultExcludes';
// -- save_memory --
export const MEMORY_TOOL_NAME = 'save_memory';
export const MEMORY_PARAM_FACT = 'fact';
export const MEMORY_PARAM_SCOPE = 'scope';
// -- get_internal_docs --
export const GET_INTERNAL_DOCS_TOOL_NAME = 'get_internal_docs';
@@ -59,6 +59,7 @@ import {
READ_MANY_PARAM_RECURSIVE,
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
MEMORY_PARAM_FACT,
MEMORY_PARAM_SCOPE,
TODOS_PARAM_TODOS,
TODOS_ITEM_PARAM_DESCRIPTION,
TODOS_ITEM_PARAM_STATUS,
@@ -513,13 +514,13 @@ Use this tool when the user's query implies needing the content of several files
save_memory: {
name: MEMORY_TOOL_NAME,
description: `
Saves concise global user context (preferences, facts) for use across ALL workspaces.
Saves concise user context (preferences, facts) for use across future sessions.
### CRITICAL: GLOBAL CONTEXT ONLY
NEVER save workspace-specific context, local paths, or commands (e.g. "The entry point is src/index.js", "The test command is npm test"). These are local to the current workspace and must NOT be saved globally. EXCLUSIVELY for context relevant across ALL workspaces.
Supports two scopes:
- **global** (default): Cross-project preferences loaded in every workspace. Use for "Remember X" or clear personal facts.
- **project**: Facts specific to the current workspace, private to the user (not committed to the repo). Use for local dev setup notes, project-specific workflows, or personal reminders about this codebase.
- Use for "Remember X" or clear personal facts.
- Do NOT use for session context.`,
Do NOT use for session-specific context or temporary data.`,
parametersJsonSchema: {
type: 'object',
properties: {
@@ -528,6 +529,12 @@ NEVER save workspace-specific context, local paths, or commands (e.g. "The entry
description:
'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
},
[MEMORY_PARAM_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: [MEMORY_PARAM_FACT],
additionalProperties: false,
@@ -59,6 +59,7 @@ import {
READ_MANY_PARAM_RECURSIVE,
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
MEMORY_PARAM_FACT,
MEMORY_PARAM_SCOPE,
TODOS_PARAM_TODOS,
TODOS_ITEM_PARAM_DESCRIPTION,
TODOS_ITEM_PARAM_STATUS,
@@ -495,14 +496,20 @@ Use this tool when the user's query implies needing the content of several files
save_memory: {
name: MEMORY_TOOL_NAME,
description: `Persists global preferences or facts across ALL future sessions. Use this for recurring instructions like coding styles or tool aliases. Unlike '${WRITE_FILE_TOOL_NAME}', which is for project-specific files, this appends to a global memory file loaded in every workspace. If you are unsure whether a fact should be remembered globally, ask the user first. CRITICAL: Do not use for session-specific context or temporary data.`,
description: `Persists preferences or facts across ALL future sessions. Supports two scopes: 'global' (default) for cross-project preferences loaded in every workspace, and 'project' for facts specific to the current workspace that are private to the user (not committed to the repo). Use 'project' scope for things like local dev setup notes, project-specific workflows, or personal reminders about this codebase. CRITICAL: Do not use for session-specific context or temporary data.`,
parametersJsonSchema: {
type: 'object',
properties: {
[MEMORY_PARAM_FACT]: {
type: 'string',
description:
"A concise, global fact or preference (e.g., 'I prefer using tabs'). Do not include local paths or project-specific names.",
'A concise fact or preference to remember. Should be a clear, self-contained statement.',
},
[MEMORY_PARAM_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: [MEMORY_PARAM_FACT],
+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');
}
});
});
});
+46 -14
View File
@@ -61,6 +61,7 @@ export function getAllGeminiMdFilenames(): string[] {
interface SaveMemoryParams {
fact: string;
scope?: 'global' | 'project';
modified_by_user?: boolean;
modified_content?: string;
}
@@ -69,6 +70,10 @@ export function getGlobalMemoryFilePath(): string {
return path.join(Storage.getGlobalGeminiDir(), getCurrentGeminiMdFilename());
}
export function getProjectMemoryFilePath(storage: Storage): string {
return path.join(storage.getProjectMemoryDir(), getCurrentGeminiMdFilename());
}
/**
* Ensures proper newline separation before appending content.
*/
@@ -82,11 +87,11 @@ function ensureNewlineSeparation(currentContent: string): string {
}
/**
* Reads the current content of the memory file
* Reads the current content of a memory file at the given path.
*/
async function readMemoryFileContent(): Promise<string> {
async function readMemoryFileContent(filePath: string): Promise<string> {
try {
return await fs.readFile(getGlobalMemoryFilePath(), 'utf-8');
return await fs.readFile(filePath, 'utf-8');
} catch (err) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = err as Error & { code?: string };
@@ -146,32 +151,42 @@ class MemoryToolInvocation extends BaseToolInvocation<
> {
private static readonly allowlist: Set<string> = new Set();
private proposedNewContent: string | undefined;
private readonly storage: Storage | undefined;
constructor(
params: SaveMemoryParams,
messageBus: MessageBus,
toolName?: string,
displayName?: string,
storage?: Storage,
) {
super(params, messageBus, toolName, displayName);
this.storage = storage;
}
private getMemoryFilePath(): string {
if (this.params.scope === 'project' && this.storage) {
return getProjectMemoryFilePath(this.storage);
}
return getGlobalMemoryFilePath();
}
getDescription(): string {
const memoryFilePath = getGlobalMemoryFilePath();
const memoryFilePath = this.getMemoryFilePath();
return `in ${tildeifyPath(memoryFilePath)}`;
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolEditConfirmationDetails | false> {
const memoryFilePath = getGlobalMemoryFilePath();
const memoryFilePath = this.getMemoryFilePath();
const allowlistKey = memoryFilePath;
if (MemoryToolInvocation.allowlist.has(allowlistKey)) {
return false;
}
const currentContent = await readMemoryFileContent();
const currentContent = await readMemoryFileContent(memoryFilePath);
const { fact, modified_by_user, modified_content } = this.params;
// If an attacker injects modified_content, use it for the diff
@@ -213,6 +228,7 @@ class MemoryToolInvocation extends BaseToolInvocation<
async execute(_signal: AbortSignal): Promise<ToolResult> {
const { fact, modified_by_user, modified_content } = this.params;
const memoryFilePath = this.getMemoryFilePath();
try {
let contentToWrite: string;
@@ -233,17 +249,17 @@ class MemoryToolInvocation extends BaseToolInvocation<
// This case can be hit in flows without a confirmation step (e.g., --auto-confirm).
// As a fallback, we recompute the content now. This is safe because
// computeNewContent sanitizes the input.
const currentContent = await readMemoryFileContent();
const currentContent = await readMemoryFileContent(memoryFilePath);
this.proposedNewContent = computeNewContent(currentContent, fact);
}
contentToWrite = this.proposedNewContent;
successMessage = `Okay, I've remembered that: "${sanitizedFact}"`;
}
await fs.mkdir(path.dirname(getGlobalMemoryFilePath()), {
await fs.mkdir(path.dirname(memoryFilePath), {
recursive: true,
});
await fs.writeFile(getGlobalMemoryFilePath(), contentToWrite, 'utf-8');
await fs.writeFile(memoryFilePath, contentToWrite, 'utf-8');
return {
llmContent: JSON.stringify({
@@ -275,8 +291,9 @@ export class MemoryTool
implements ModifiableDeclarativeTool<SaveMemoryParams>
{
static readonly Name = MEMORY_TOOL_NAME;
private readonly storage: Storage | undefined;
constructor(messageBus: MessageBus) {
constructor(messageBus: MessageBus, storage?: Storage) {
super(
MemoryTool.Name,
'SaveMemory',
@@ -287,6 +304,14 @@ export class MemoryTool
true,
false,
);
this.storage = storage;
}
private resolveMemoryFilePath(params: SaveMemoryParams): string {
if (params.scope === 'project' && this.storage) {
return getProjectMemoryFilePath(this.storage);
}
return getGlobalMemoryFilePath();
}
protected override validateToolParamValues(
@@ -296,6 +321,10 @@ export class MemoryTool
return 'Parameter "fact" must be a non-empty string.';
}
if (params.scope === 'project' && !this.storage) {
return 'Project-level memory is not available: storage is not initialized.';
}
return null;
}
@@ -310,6 +339,7 @@ export class MemoryTool
messageBus,
toolName ?? this.name,
displayName ?? this.displayName,
this.storage,
);
}
@@ -319,11 +349,13 @@ export class MemoryTool
getModifyContext(_abortSignal: AbortSignal): ModifyContext<SaveMemoryParams> {
return {
getFilePath: (_params: SaveMemoryParams) => getGlobalMemoryFilePath(),
getCurrentContent: async (_params: SaveMemoryParams): Promise<string> =>
readMemoryFileContent(),
getFilePath: (params: SaveMemoryParams) =>
this.resolveMemoryFilePath(params),
getCurrentContent: async (params: SaveMemoryParams): Promise<string> =>
readMemoryFileContent(this.resolveMemoryFilePath(params)),
getProposedContent: async (params: SaveMemoryParams): Promise<string> => {
const currentContent = await readMemoryFileContent();
const filePath = this.resolveMemoryFilePath(params);
const currentContent = await readMemoryFileContent(filePath);
const { fact, modified_by_user, modified_content } = params;
// Ensure the editor is populated with the same content
// that the confirmation diff would show.