feat(memory): add Auto Memory inbox flow with canonical-patch contract (#26338)

This commit is contained in:
Sandy Tao
2026-05-04 12:07:13 -07:00
committed by GitHub
parent 60a6a47d56
commit a7beb890d0
26 changed files with 4279 additions and 115 deletions
+799
View File
@@ -13,11 +13,15 @@ import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { flattenMemory } from '../config/memory.js';
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
import { getGlobalMemoryFilePath } from '../tools/memoryTool.js';
import {
type AppliedSkillPatchTarget,
applyParsedPatchesWithAllowedRoots,
applyParsedSkillPatches,
canonicalizeAllowedPatchRoots,
hasParsedPatchHunks,
isProjectSkillPatchTarget,
resolveTargetWithinAllowedRoots,
validateParsedSkillPatchHeaders,
} from '../services/memoryPatchUtils.js';
import { readExtractionState } from '../services/memoryService.js';
@@ -338,6 +342,46 @@ export interface InboxPatch {
extractedAt?: string;
}
export type InboxMemoryPatchKind = 'private' | 'global';
/**
* One target file inside a memory patch (most patches will have a single entry).
*/
export interface InboxMemoryPatchEntry {
/** Absolute path of the markdown file the patch will modify. */
targetPath: string;
/** Unified diff for this single file (used for UI preview). */
diffContent: string;
/** True when this entry creates a new file (`/dev/null` source). */
isNewFile: boolean;
}
/**
* Represents the AGGREGATED inbox state for one memory kind. Even when the
* extraction agent has produced multiple `.patch` files under
* `<memoryDir>/.inbox/<kind>/` (e.g. across several sessions), the inbox
* surfaces them as ONE entry per kind. Apply runs each underlying patch in
* sequence; Dismiss removes them all.
*/
export interface InboxMemoryPatch {
/** Memory tier — one entry per kind in the inbox. */
kind: InboxMemoryPatchKind;
/**
* Stable identifier for this consolidated entry. Set to the kind itself
* (`"private"` or `"global"`); kept in the type for backwards-compat with
* the per-file API the dialog passes through.
*/
relativePath: string;
/** Display name shown in the inbox row (e.g. `"Private memory"`). */
name: string;
/** All hunks from all underlying source patches, concatenated in order. */
entries: InboxMemoryPatchEntry[];
/** Basenames of the underlying `.patch` files being aggregated. */
sourceFiles: string[];
/** Most recent mtime across the source files (ISO string), if known. */
extractedAt?: string;
}
interface StagedInboxPatchTarget {
targetPath: string;
tempPath: string;
@@ -372,6 +416,97 @@ function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function getMemoryPatchRoot(
memoryDir: string,
kind: InboxMemoryPatchKind,
): string {
return path.join(memoryDir, '.inbox', kind);
}
function isSubpathOrSame(childPath: string, parentPath: string): boolean {
const relativePath = path.relative(parentPath, childPath);
return (
relativePath === '' ||
(!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
);
}
function normalizeInboxMemoryPatchPath(
relativePath: string,
): string | undefined {
if (
relativePath.length === 0 ||
path.isAbsolute(relativePath) ||
relativePath.includes('\\')
) {
return undefined;
}
const normalizedPath = path.posix.normalize(relativePath);
if (
normalizedPath === '.' ||
normalizedPath.startsWith('../') ||
normalizedPath === '..' ||
!normalizedPath.endsWith('.patch')
) {
return undefined;
}
return normalizedPath;
}
/**
* Returns the directory roots (or single-file allowlists) that a memory patch
* of the given kind is allowed to modify. Memory patch headers must reference
* paths inside / equal to one of these entries after canonical resolution.
*
* - `private` allows any markdown file inside the project memory directory.
* - `global` is intentionally a single-file allowlist: the only writeable
* global file is the personal `~/.gemini/GEMINI.md`. Other files under
* `~/.gemini/` (settings, credentials, oauth, keybindings, etc.) are off-limits.
*/
export function getAllowedMemoryPatchRoots(
config: Config,
kind: InboxMemoryPatchKind,
): string[] {
switch (kind) {
case 'private':
return [path.resolve(config.storage.getProjectMemoryTempDir())];
case 'global':
return [path.resolve(getGlobalMemoryFilePath())];
default:
throw new Error(`Unknown memory patch kind: ${kind as string}`);
}
}
async function getFileMtimeIso(filePath: string): Promise<string | undefined> {
try {
const stats = await fs.stat(filePath);
return stats.mtime.toISOString();
} catch {
return undefined;
}
}
async function getInboxMemoryPatchSourcePath(
config: Config,
kind: InboxMemoryPatchKind,
relativePath: string,
): Promise<string | undefined> {
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
if (!normalizedPath) {
return undefined;
}
const patchRoot = path.resolve(
getMemoryPatchRoot(config.storage.getProjectMemoryTempDir(), kind),
);
const sourcePath = path.resolve(patchRoot, ...normalizedPath.split('/'));
if (!isSubpathOrSame(sourcePath, patchRoot)) {
return undefined;
}
return sourcePath;
}
async function patchTargetsProjectSkills(
targetPaths: string[],
config: Config,
@@ -395,6 +530,670 @@ async function getPatchExtractedAt(
}
}
function formatMemoryKindLabel(kind: InboxMemoryPatchKind): string {
switch (kind) {
case 'private':
return 'Private memory';
case 'global':
return 'Global memory';
default:
return kind;
}
}
/**
* Returns the absolute paths of every `.patch` file currently in the kind's
* inbox directory (sorted by basename for stable ordering at apply time).
*
* NOTE: this is a raw filesystem listing — it does NOT validate patch shape
* or that targets fall inside the kind's allowed root. Callers that need
* "what the user actually sees in the inbox" should use `listValidInboxPatchFiles`.
*/
async function listInboxPatchFiles(
config: Config,
kind: InboxMemoryPatchKind,
): Promise<string[]> {
const patchRoot = getMemoryPatchRoot(
config.storage.getProjectMemoryTempDir(),
kind,
);
const found: string[] = [];
async function walk(currentDir: string): Promise<void> {
let dirEntries: Array<import('node:fs').Dirent>;
try {
dirEntries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of dirEntries) {
const entryPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
continue;
}
if (entry.isFile() && entry.name.endsWith('.patch')) {
found.push(entryPath);
}
}
}
await walk(patchRoot);
return found.sort();
}
/**
* Returns only the inbox patch files that pass the same validation as the
* inbox listing (parseable, has hunks, valid headers, targets in the
* kind's allowed root). Used by aggregate apply so the user only ever sees
* results for patches the inbox actually surfaced.
*/
async function listValidInboxPatchFiles(
config: Config,
kind: InboxMemoryPatchKind,
): Promise<string[]> {
const patchFiles = await listInboxPatchFiles(config, kind);
if (patchFiles.length === 0) {
return [];
}
const allowedRoots = await canonicalizeAllowedPatchRoots(
getAllowedMemoryPatchRoots(config, kind),
);
const valid: string[] = [];
for (const sourcePath of patchFiles) {
let content: string;
try {
content = await fs.readFile(sourcePath, 'utf-8');
} catch {
continue;
}
let parsed: Diff.StructuredPatch[];
try {
parsed = Diff.parsePatch(content);
} catch {
continue;
}
if (!hasParsedPatchHunks(parsed)) {
continue;
}
const validated = validateParsedSkillPatchHeaders(parsed);
if (!validated.success) {
continue;
}
const targetsAllAllowed = await Promise.all(
validated.patches.map(
async (header) =>
(await resolveTargetWithinAllowedRoots(
header.targetPath,
allowedRoots,
)) !== undefined,
),
);
if (!targetsAllAllowed.every(Boolean)) {
continue;
}
valid.push(sourcePath);
}
return valid;
}
/**
* Scans `<memoryDir>/.inbox/{private,global}/` and returns ONE consolidated
* inbox entry per kind. Each entry aggregates all hunks from every valid
* underlying `.patch` file. Patches that fail validation (unparseable, no
* hunks, target outside allowed root) are silently skipped so they don't
* pollute the inbox UI.
*/
export async function listInboxMemoryPatches(
config: Config,
): Promise<InboxMemoryPatch[]> {
const kinds: InboxMemoryPatchKind[] = ['private', 'global'];
const aggregated: InboxMemoryPatch[] = [];
for (const kind of kinds) {
const allowedRoots = await canonicalizeAllowedPatchRoots(
getAllowedMemoryPatchRoots(config, kind),
);
const patchFiles = await listInboxPatchFiles(config, kind);
const aggregatedEntries: InboxMemoryPatchEntry[] = [];
const sourceFiles: string[] = [];
let latestMtime: string | undefined;
for (const sourcePath of patchFiles) {
let content: string;
try {
content = await fs.readFile(sourcePath, 'utf-8');
} catch {
continue;
}
let parsed: Diff.StructuredPatch[];
try {
parsed = Diff.parsePatch(content);
} catch {
continue;
}
if (!hasParsedPatchHunks(parsed)) {
continue;
}
const validated = validateParsedSkillPatchHeaders(parsed);
if (!validated.success) {
continue;
}
// Skip the entire source file if ANY of its targets escapes the kind's
// allowed root.
const targetsAllAllowed = await Promise.all(
validated.patches.map(
async (header) =>
(await resolveTargetWithinAllowedRoots(
header.targetPath,
allowedRoots,
)) !== undefined,
),
);
if (!targetsAllAllowed.every(Boolean)) {
continue;
}
for (const [index, header] of validated.patches.entries()) {
aggregatedEntries.push({
targetPath: header.targetPath,
isNewFile: header.isNewFile,
diffContent: formatParsedDiff(parsed[index]),
});
}
sourceFiles.push(path.basename(sourcePath));
const mtime = await getFileMtimeIso(sourcePath);
if (mtime && (!latestMtime || mtime > latestMtime)) {
latestMtime = mtime;
}
}
if (aggregatedEntries.length === 0) {
continue;
}
aggregated.push({
kind,
relativePath: kind,
name: formatMemoryKindLabel(kind),
entries: aggregatedEntries,
sourceFiles,
extractedAt: latestMtime,
});
}
return aggregated;
}
/**
* Applies an inbox memory patch atomically and removes the patch on success.
*
* Process:
* 1. Parse + validate the patch headers (absolute paths only, no `a/`/`b/`).
* 2. Dry-run the patch against the current target content (or empty for
* `/dev/null` creation patches).
* 3. Stage the patched content to a temp file, then rename into place.
* 4. On any failure, restore previous content from the staged snapshot and
* leave the inbox patch intact for retry.
*/
/**
* Applies one inbox memory entry. Two modes:
* - Aggregate mode (`relativePath === kind`): walk every `.patch` file in
* the kind's inbox directory and apply each one in lexical order. Each
* file is its own atomic transaction; failures don't block subsequent
* successes. Returns an aggregated summary (e.g. "Applied 3 of 4 sub-
* patches; 1 failed: …").
* - Single-file mode (legacy): `relativePath` points at a specific
* `.patch` filename. Used by tests and direct callers.
*/
export async function applyInboxMemoryPatch(
config: Config,
kind: InboxMemoryPatchKind,
relativePath: string,
): Promise<{ success: boolean; message: string }> {
if (relativePath === kind) {
return applyAllInboxPatchesForKind(config, kind);
}
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
if (!normalizedPath) {
return { success: false, message: 'Invalid memory patch path.' };
}
const sourcePath = await getInboxMemoryPatchSourcePath(
config,
kind,
normalizedPath,
);
if (!sourcePath) {
return { success: false, message: 'Invalid memory patch path.' };
}
return applyMemoryPatchFile(config, kind, sourcePath, normalizedPath);
}
async function applyAllInboxPatchesForKind(
config: Config,
kind: InboxMemoryPatchKind,
): Promise<{ success: boolean; message: string }> {
// Only attempt patches the user actually saw in the inbox listing.
// Files that were filtered (bad headers, escape allowed root, etc.) stay
// on disk untouched.
const patchFiles = await listValidInboxPatchFiles(config, kind);
if (patchFiles.length === 0) {
return {
success: false,
message: `No ${kind} memory patches in inbox.`,
};
}
const successes: string[] = [];
const failures: Array<{ name: string; reason: string }> = [];
let pointersAddedAcrossPatches: string[] = [];
for (const sourcePath of patchFiles) {
const basename = path.basename(sourcePath);
const result = await applyMemoryPatchFile(
config,
kind,
sourcePath,
basename,
);
if (result.success) {
successes.push(basename);
// Surface auto-added MEMORY.md pointer info if present.
const pointerMatch = result.message.match(
/Auto-added MEMORY\.md pointer for ([^.]+)\./,
);
if (pointerMatch) {
pointersAddedAcrossPatches.push(pointerMatch[1]);
}
} else {
failures.push({ name: basename, reason: result.message });
}
}
// De-dup pointer notes (same sibling could have been mentioned twice).
pointersAddedAcrossPatches = Array.from(new Set(pointersAddedAcrossPatches));
const total = successes.length + failures.length;
if (failures.length === 0) {
const pointerNote =
pointersAddedAcrossPatches.length > 0
? ` Auto-added MEMORY.md pointer(s) for ${pointersAddedAcrossPatches.join('; ')}.`
: '';
return {
success: true,
message: `Applied all ${successes.length} ${kind} memory patch${
successes.length === 1 ? '' : 'es'
}.${pointerNote}`,
};
}
const failureSummary = failures
.map((f) => `"${f.name}" — ${f.reason}`)
.join('; ');
// Any failure → success=false so the dialog keeps the inbox entry visible
// (the user needs to see and retry/dismiss the remaining sub-patches).
// The successful sub-patches have already been removed from disk by
// applyMemoryPatchFile, so the next listing will show only the failures.
return {
success: false,
message:
`Applied ${successes.length} of ${total} ${kind} memory patches. ` +
`${failures.length} failed: ${failureSummary}`,
};
}
async function canonicalizeDirIfPresent(dirPath: string): Promise<string> {
try {
return await fs.realpath(dirPath);
} catch {
return path.resolve(dirPath);
}
}
/**
* Returns the basenames of any sibling .md files (not MEMORY.md itself) that
* are being CREATED by this patch under `<memoryDir>/` directly.
*/
function findSiblingCreations(
appliedResults: readonly AppliedSkillPatchTarget[],
memoryDir: string,
): AppliedSkillPatchTarget[] {
return appliedResults.filter((entry) => {
if (!entry.isNewFile) return false;
const targetDir = path.dirname(path.resolve(entry.targetPath));
if (targetDir !== memoryDir) return false;
const basename = path.basename(entry.targetPath);
if (basename.toLowerCase() === 'memory.md') return false;
return basename.toLowerCase().endsWith('.md');
});
}
interface AutoPointerAugmentation {
/** Patch results, possibly with a synthesized/extended MEMORY.md entry. */
results: AppliedSkillPatchTarget[];
/** Sibling basenames a pointer was auto-added for (empty if none). */
pointersAdded: string[];
}
/**
* MEMORY.md is the index that gets injected into future agent contexts.
* Sibling .md files in `<memoryDir>/` are loaded ON DEMAND by the runtime
* agent via `read_file` — but only IF MEMORY.md references them by name
* (see `getUserProjectMemoryPaths`).
*
* If a private patch creates a sibling without also referencing it from
* MEMORY.md, the new file would never be discoverable. Rather than rejecting
* the patch (bad UX), we auto-bundle a MEMORY.md update that adds a
* one-line pointer per orphan sibling. The augmented entry is then committed
* atomically alongside the rest of the patch.
*
* If the patch already updates/creates MEMORY.md and the new content already
* references the sibling, no augmentation is needed.
*/
async function augmentWithAutoPointers(
config: Config,
appliedResults: readonly AppliedSkillPatchTarget[],
): Promise<AutoPointerAugmentation> {
const memoryDir = await canonicalizeDirIfPresent(
config.storage.getProjectMemoryTempDir(),
);
const memoryMdPath = path.join(memoryDir, 'MEMORY.md');
const siblingCreations = findSiblingCreations(appliedResults, memoryDir);
if (siblingCreations.length === 0) {
return { results: [...appliedResults], pointersAdded: [] };
}
// Locate (or initialize) the MEMORY.md entry we'll mutate.
const existingIdx = appliedResults.findIndex(
(entry) => path.resolve(entry.targetPath) === memoryMdPath,
);
let memoryEntry: AppliedSkillPatchTarget;
if (existingIdx >= 0) {
memoryEntry = { ...appliedResults[existingIdx] };
} else {
let originalContent = '';
let isNewFile = true;
try {
originalContent = await fs.readFile(memoryMdPath, 'utf-8');
isNewFile = false;
} catch {
// MEMORY.md doesn't exist yet — we'll create it with a default heading.
}
memoryEntry = {
targetPath: memoryMdPath,
original: originalContent,
patched: isNewFile ? '# Project Memory\n' : originalContent,
isNewFile,
};
}
const pointersAdded: string[] = [];
for (const sibling of siblingCreations) {
const basename = path.basename(sibling.targetPath);
// Resolve to absolute path so the runtime agent can `read_file` the
// sibling directly without needing to know <memoryDir>.
const absoluteTarget = path.resolve(sibling.targetPath);
// Existing reference can be by either basename or absolute path; both count.
if (
memoryEntry.patched.includes(basename) ||
memoryEntry.patched.includes(absoluteTarget)
) {
continue; // Already referenced.
}
const stem = basename.replace(/\.md$/i, '').replace(/[-_]/g, ' ').trim();
const pointer = `- See ${absoluteTarget} for ${stem || basename} notes.`;
memoryEntry.patched = memoryEntry.patched.endsWith('\n')
? `${memoryEntry.patched}${pointer}\n`
: `${memoryEntry.patched}\n${pointer}\n`;
pointersAdded.push(basename);
}
if (pointersAdded.length === 0) {
return { results: [...appliedResults], pointersAdded: [] };
}
const results = [...appliedResults];
if (existingIdx >= 0) {
results[existingIdx] = memoryEntry;
} else {
results.push(memoryEntry);
}
return { results, pointersAdded };
}
/**
* Internal helper: parses, validates, and atomically commits a memory patch
* file at a known absolute path. Separated from `applyInboxMemoryPatch` so the
* path-resolution and patch-apply concerns stay testable independently.
*/
async function applyMemoryPatchFile(
config: Config,
kind: InboxMemoryPatchKind,
patchPath: string,
displayName: string,
): Promise<{ success: boolean; message: string }> {
let content: string;
try {
content = await fs.readFile(patchPath, 'utf-8');
} catch {
return {
success: false,
message: `Memory patch "${displayName}" not found in inbox.`,
};
}
let parsed: Diff.StructuredPatch[];
try {
parsed = Diff.parsePatch(content);
} catch (error) {
return {
success: false,
message: `Failed to parse memory patch "${displayName}": ${getErrorMessage(error)}`,
};
}
if (!hasParsedPatchHunks(parsed)) {
return {
success: false,
message: `Memory patch "${displayName}" contains no valid hunks.`,
};
}
const allowedRoots = await canonicalizeAllowedPatchRoots(
getAllowedMemoryPatchRoots(config, kind),
);
const applied = await applyParsedPatchesWithAllowedRoots(
parsed,
allowedRoots,
);
if (!applied.success) {
switch (applied.reason) {
case 'missingTargetPath':
return {
success: false,
message: `Memory patch "${displayName}" is missing a target file path.`,
};
case 'invalidPatchHeaders':
return {
success: false,
message: `Memory patch "${displayName}" has invalid diff headers.`,
};
case 'outsideAllowedRoots':
return {
success: false,
message: `Memory patch "${displayName}" targets a file outside the ${kind} memory root: ${applied.targetPath}`,
};
case 'newFileAlreadyExists':
return {
success: false,
message: `Memory patch "${displayName}" declares a new file, but the target already exists: ${applied.targetPath}`,
};
case 'targetNotFound':
return {
success: false,
message: `Target file not found: ${applied.targetPath}`,
};
case 'doesNotApply':
return {
success: false,
message: applied.isNewFile
? `Memory patch "${displayName}" failed to apply for new file ${applied.targetPath}.`
: `Memory patch does not apply cleanly to ${applied.targetPath}.`,
};
default:
return {
success: false,
message: `Memory patch "${displayName}" could not be applied.`,
};
}
}
// Auto-bundle a MEMORY.md pointer for any sibling .md the patch creates
// without referencing it from MEMORY.md. Without that pointer the new file
// would never be loaded into a future session (see augmentWithAutoPointers).
let pointersAdded: string[] = [];
let resultsToCommit: AppliedSkillPatchTarget[] = [...applied.results];
if (kind === 'private') {
const augmented = await augmentWithAutoPointers(config, applied.results);
resultsToCommit = augmented.results;
pointersAdded = augmented.pointersAdded;
}
let stagedTargets: StagedInboxPatchTarget[];
try {
stagedTargets = await stageInboxPatchTargets(resultsToCommit);
} catch (error) {
return {
success: false,
message: `Memory patch "${displayName}" could not be staged: ${getErrorMessage(error)}.`,
};
}
const committedTargets: StagedInboxPatchTarget[] = [];
try {
for (const stagedTarget of stagedTargets) {
await fs.rename(stagedTarget.tempPath, stagedTarget.targetPath);
committedTargets.push(stagedTarget);
}
} catch (error) {
for (const committedTarget of committedTargets.reverse()) {
try {
await restoreCommittedInboxPatchTarget(committedTarget);
} catch {
// Best-effort rollback. We still report the commit failure below.
}
}
await cleanupStagedInboxPatchTargets(
stagedTargets.filter((target) => !committedTargets.includes(target)),
);
return {
success: false,
message: `Memory patch "${displayName}" could not be applied atomically: ${getErrorMessage(error)}.`,
};
}
await fs.unlink(patchPath);
const fileCount = resultsToCommit.length;
const baseMessage = `Applied memory patch to ${fileCount} file${fileCount !== 1 ? 's' : ''}.`;
const pointerNote =
pointersAdded.length > 0
? ` Auto-added MEMORY.md pointer for ${pointersAdded
.map((name) => `"${name}"`)
.join(', ')} so the new sibling file is discoverable.`
: '';
return {
success: true,
message: `${baseMessage}${pointerNote}`,
};
}
/**
* Removes inbox memory patch(es) without applying. Two modes:
* - Aggregate (`relativePath === kind`): unlink every `.patch` file in the
* kind's inbox directory. Used by the consolidated inbox UI's Dismiss.
* - Single-file (legacy): unlink one specific `.patch` file.
*/
export async function dismissInboxMemoryPatch(
config: Config,
kind: InboxMemoryPatchKind,
relativePath: string,
): Promise<{ success: boolean; message: string }> {
if (relativePath === kind) {
// Dismiss the same set of files the listing surfaced — leave the
// already-filtered (bad-target, malformed) files alone for forensic
// inspection.
const patchFiles = await listValidInboxPatchFiles(config, kind);
if (patchFiles.length === 0) {
return {
success: false,
message: `No ${kind} memory patches in inbox.`,
};
}
let removed = 0;
for (const sourcePath of patchFiles) {
try {
await fs.unlink(sourcePath);
removed += 1;
} catch {
// Best-effort: keep going if one delete fails.
}
}
return {
success: removed > 0,
message: `Dismissed ${removed} ${kind} memory patch${
removed === 1 ? '' : 'es'
} from inbox.`,
};
}
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
if (!normalizedPath) {
return { success: false, message: 'Invalid memory patch path.' };
}
const sourcePath = await getInboxMemoryPatchSourcePath(
config,
kind,
normalizedPath,
);
if (!sourcePath) {
return { success: false, message: 'Invalid memory patch path.' };
}
try {
await fs.access(sourcePath);
} catch {
return {
success: false,
message: `Memory patch "${normalizedPath}" not found in inbox.`,
};
}
await fs.unlink(sourcePath);
return {
success: true,
message: `Dismissed "${normalizedPath}" from inbox.`,
};
}
async function findNearestExistingDirectory(
startPath: string,
): Promise<string> {