mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-27 11:47:46 -07:00
feat(memory): add Auto Memory inbox flow with canonical-patch contract (#26338)
This commit is contained in:
@@ -12,9 +12,12 @@ import type { Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import {
|
||||
addMemory,
|
||||
applyInboxMemoryPatch,
|
||||
dismissInboxSkill,
|
||||
dismissInboxMemoryPatch,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listInboxMemoryPatches,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
listMemoryFiles,
|
||||
@@ -31,6 +34,7 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
|
||||
vi.mock('../config/storage.js', () => ({
|
||||
Storage: {
|
||||
getUserSkillsDir: vi.fn(),
|
||||
getGlobalGeminiDir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -315,6 +319,619 @@ describe('memory commands', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory patch inbox', () => {
|
||||
let tmpDir: string;
|
||||
let memoryTempDir: string;
|
||||
let projectRoot: string;
|
||||
let globalMemoryDir: string;
|
||||
let patchConfig: Config;
|
||||
|
||||
function buildUpdatePatch(
|
||||
absoluteTargetPath: string,
|
||||
original: string,
|
||||
updated: string,
|
||||
): string {
|
||||
// Minimal one-hunk patch that replaces `original` with `updated`.
|
||||
const oldLines = original === '' ? 0 : original.split('\n').length - 1;
|
||||
const newLines = updated === '' ? 0 : updated.split('\n').length - 1;
|
||||
const removed = original
|
||||
.split('\n')
|
||||
.slice(0, oldLines)
|
||||
.map((line) => `-${line}`);
|
||||
const added = updated
|
||||
.split('\n')
|
||||
.slice(0, newLines)
|
||||
.map((line) => `+${line}`);
|
||||
return [
|
||||
`--- ${absoluteTargetPath}`,
|
||||
`+++ ${absoluteTargetPath}`,
|
||||
`@@ -1,${oldLines} +1,${newLines} @@`,
|
||||
...removed,
|
||||
...added,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildCreationPatch(
|
||||
absoluteTargetPath: string,
|
||||
content: string,
|
||||
): string {
|
||||
const contentLines = content.split('\n');
|
||||
const lineCount = content.endsWith('\n')
|
||||
? contentLines.length - 1
|
||||
: contentLines.length;
|
||||
const additions = (
|
||||
content.endsWith('\n') ? contentLines.slice(0, -1) : contentLines
|
||||
).map((line) => `+${line}`);
|
||||
return [
|
||||
`--- /dev/null`,
|
||||
`+++ ${absoluteTargetPath}`,
|
||||
`@@ -0,0 +1,${lineCount} @@`,
|
||||
...additions,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-patch-test-'));
|
||||
// Canonicalize so test-side paths match production's
|
||||
// canonicalizeDirIfPresent → fs.realpath. On Windows runners
|
||||
// os.tmpdir() returns the 8.3 short form (C:\Users\RUNNER~1\...) but
|
||||
// fs.realpath expands it to the long form (C:\Users\runneradmin\...),
|
||||
// which would otherwise break the auto-pointer absolute-path asserts.
|
||||
tmpDir = await fs.realpath(tmpDir);
|
||||
memoryTempDir = path.join(tmpDir, 'memory-temp');
|
||||
projectRoot = path.join(tmpDir, 'project');
|
||||
globalMemoryDir = path.join(tmpDir, 'global');
|
||||
await fs.mkdir(memoryTempDir, { recursive: true });
|
||||
await fs.mkdir(projectRoot, { recursive: true });
|
||||
await fs.mkdir(globalMemoryDir, { recursive: true });
|
||||
|
||||
patchConfig = {
|
||||
storage: {
|
||||
getProjectMemoryTempDir: () => memoryTempDir,
|
||||
getProjectMemoryDir: () => memoryTempDir,
|
||||
},
|
||||
isTrustedFolder: () => true,
|
||||
} as unknown as Config;
|
||||
vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue(globalMemoryDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('aggregates all .patch files of a kind into a single inbox entry', async () => {
|
||||
// Multiple physical .patch files in the kind dir → ONE consolidated
|
||||
// inbox entry per kind, with all hunks merged into entries[].
|
||||
const target = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(target, '- old\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'a-update.patch'),
|
||||
buildUpdatePatch(target, '- old\n', '- new\n'),
|
||||
);
|
||||
// Second source patch — same kind, different hunk.
|
||||
const sibling = path.join(memoryTempDir, 'topic.md');
|
||||
await fs.writeFile(sibling, 'topic A\n');
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'b-topic.patch'),
|
||||
buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
|
||||
);
|
||||
|
||||
const patches = await listInboxMemoryPatches(patchConfig);
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
const memoryPatch = patches[0];
|
||||
expect(memoryPatch).toMatchObject({
|
||||
kind: 'private',
|
||||
relativePath: 'private',
|
||||
name: 'Private memory',
|
||||
});
|
||||
// Both source files contributed their hunks.
|
||||
expect(memoryPatch.entries).toHaveLength(2);
|
||||
expect(memoryPatch.sourceFiles).toEqual([
|
||||
'a-update.patch',
|
||||
'b-topic.patch',
|
||||
]);
|
||||
expect(memoryPatch.entries[0].targetPath).toBe(target);
|
||||
expect(memoryPatch.entries[0].isNewFile).toBe(false);
|
||||
expect(memoryPatch.entries[1].targetPath).toBe(sibling);
|
||||
expect(memoryPatch.extractedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits patches whose headers leave the allowed root from the listing', async () => {
|
||||
// Bad patches must NOT show up in the inbox at all — listing filters
|
||||
// them out so the user only ever sees actionable items. (They'd also
|
||||
// be rejected at Apply time, but we don't want to surface them.)
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'escape.patch'),
|
||||
buildCreationPatch(path.join(projectRoot, 'GEMINI.md'), 'Hi.\n'),
|
||||
);
|
||||
|
||||
const patches = await listInboxMemoryPatches(patchConfig);
|
||||
expect(patches).toHaveLength(0);
|
||||
|
||||
// Direct apply still rejects it (defense-in-depth).
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'escape.patch',
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/outside the private memory root/i);
|
||||
});
|
||||
|
||||
it('omits global patches with disallowed targets from the listing', async () => {
|
||||
// Same defense for the global tier: only ~/.gemini/GEMINI.md is allowed.
|
||||
// memory.md (legacy lowercase), sibling .md files, and settings.json all
|
||||
// get filtered out of the listing instead of confusing the user.
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'wrong-name.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'memory.md'),
|
||||
'rejected\n',
|
||||
),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'sibling.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'notes.md'),
|
||||
'rejected\n',
|
||||
),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'settings.patch'),
|
||||
buildCreationPatch(path.join(globalMemoryDir, 'settings.json'), '{}\n'),
|
||||
);
|
||||
|
||||
const patches = await listInboxMemoryPatches(patchConfig);
|
||||
expect(patches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('applies a private update patch and removes it from the inbox', async () => {
|
||||
const target = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(target, '- old\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'MEMORY.patch'),
|
||||
buildUpdatePatch(target, '- old\n', '- accepted\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'MEMORY.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- accepted\n');
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'MEMORY.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('applies a private creation patch with a paired MEMORY.md pointer', async () => {
|
||||
// The auto-memory contract: creating a sibling .md file requires a
|
||||
// hunk that adds a pointer to MEMORY.md (so the sibling becomes
|
||||
// discoverable to future sessions).
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(memoryMd, '# Project Memory\n');
|
||||
|
||||
const target = path.join(memoryTempDir, 'topic.md');
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
const multiHunkPatch =
|
||||
buildCreationPatch(target, '# Topic\n- new fact\n') +
|
||||
buildUpdatePatch(
|
||||
memoryMd,
|
||||
'# Project Memory\n',
|
||||
'# Project Memory\n- See topic.md for the new fact.\n',
|
||||
);
|
||||
await fs.writeFile(path.join(patchDir, 'topic.patch'), multiHunkPatch);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'topic.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'# Topic\n- new fact\n',
|
||||
);
|
||||
await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toContain(
|
||||
'See topic.md',
|
||||
);
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'topic.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('auto-bundles a MEMORY.md pointer when the patch creates an orphan sibling', async () => {
|
||||
// Sibling .md files in <memoryDir> are loaded by future sessions ONLY
|
||||
// when MEMORY.md references them. To avoid orphans, applying a sibling
|
||||
// creation patch with no MEMORY.md update auto-bundles a pointer line.
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(memoryMd, '# Project Memory\n');
|
||||
|
||||
const target = path.join(memoryTempDir, 'orphan-topic.md');
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'orphan-topic.patch'),
|
||||
buildCreationPatch(target, '# Orphan Topic\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'orphan-topic.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
|
||||
expect(result.message).toContain('"orphan-topic.md"');
|
||||
// The sibling exists.
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'# Orphan Topic\n',
|
||||
);
|
||||
// MEMORY.md now references the sibling — using ABSOLUTE PATH so a
|
||||
// future agent can `read_file` it without resolving relatives. We
|
||||
// assert the line shape is `- See <absolute>/orphan-topic.md ...` and
|
||||
// verify the path is absolute via path.isAbsolute (cross-platform —
|
||||
// the previous /^- See \/.+\/.../ regex was Unix-only and broke on
|
||||
// Windows where the absolute path is e.g. `C:\Users\...\orphan-topic.md`).
|
||||
const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
|
||||
expect(memoryAfter).toContain(target);
|
||||
const pointerLineMatch = memoryAfter.match(
|
||||
/^- See (.+orphan-topic\.md) /m,
|
||||
);
|
||||
expect(pointerLineMatch).not.toBeNull();
|
||||
expect(path.isAbsolute(pointerLineMatch![1])).toBe(true);
|
||||
// The patch was committed and removed from inbox.
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'orphan-topic.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('auto-creates MEMORY.md if it does not exist when bundling pointers', async () => {
|
||||
// No MEMORY.md on disk + a creation patch for a sibling →
|
||||
// auto-bundle should create MEMORY.md from scratch with the pointer.
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await expect(fs.access(memoryMd)).rejects.toThrow();
|
||||
|
||||
const target = path.join(memoryTempDir, 'fresh-topic.md');
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'fresh-topic.patch'),
|
||||
buildCreationPatch(target, '# Fresh Topic\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'fresh-topic.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
|
||||
const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
|
||||
expect(memoryAfter).toContain('Project Memory');
|
||||
// Pointer must be absolute so the future agent can read_file directly.
|
||||
expect(memoryAfter).toContain(target);
|
||||
});
|
||||
|
||||
it('accepts a private creation patch when MEMORY.md already references the new file', async () => {
|
||||
// If MEMORY.md was previously prepared with a pointer (e.g. by a
|
||||
// separately-applied patch), the follow-up creation patch is fine.
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(
|
||||
memoryMd,
|
||||
'# Project Memory\n- See later-topic.md for details.\n',
|
||||
);
|
||||
|
||||
const target = path.join(memoryTempDir, 'later-topic.md');
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'later-topic.patch'),
|
||||
buildCreationPatch(target, '# Later Topic\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'later-topic.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'# Later Topic\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies a global creation patch to ~/.gemini/GEMINI.md', async () => {
|
||||
const target = path.join(globalMemoryDir, 'GEMINI.md');
|
||||
// Sanity check: target does not exist before apply.
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'GEMINI.patch'),
|
||||
buildCreationPatch(target, '# Personal preferences\n- prefer X\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'global',
|
||||
'GEMINI.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'# Personal preferences\n- prefer X\n',
|
||||
);
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'GEMINI.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('applies a global update patch to ~/.gemini/GEMINI.md', async () => {
|
||||
const target = path.join(globalMemoryDir, 'GEMINI.md');
|
||||
await fs.writeFile(target, '- prefer X\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'GEMINI.patch'),
|
||||
buildUpdatePatch(target, '- prefer X\n', '- prefer Y\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'global',
|
||||
'GEMINI.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- prefer Y\n');
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'GEMINI.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('dismisses a single memory patch from the inbox (legacy single-file mode)', async () => {
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'GEMINI.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'GEMINI.md'),
|
||||
'Prefer concise.\n',
|
||||
),
|
||||
);
|
||||
|
||||
const result = await dismissInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'global',
|
||||
'GEMINI.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'GEMINI.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('apply with relativePath = kind runs every source patch in sequence', async () => {
|
||||
// Aggregate apply: pass `relativePath = kind`. Each .patch file under
|
||||
// the kind dir is applied atomically in lexical order; the result
|
||||
// message summarizes successes/failures.
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(memoryMd, '- old\n');
|
||||
const sibling = path.join(memoryTempDir, 'topic.md');
|
||||
await fs.writeFile(sibling, 'topic A\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'a-update.patch'),
|
||||
buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'b-topic.patch'),
|
||||
buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'private', // ← aggregate mode
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toMatch(/applied all 2 private memory patches/i);
|
||||
|
||||
// Both targets were updated, both source patches removed.
|
||||
await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
|
||||
await expect(fs.readFile(sibling, 'utf-8')).resolves.toBe('topic B\n');
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'a-update.patch')),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'b-topic.patch')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('aggregate apply reports successes and failures when one source patch is stale', async () => {
|
||||
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(memoryMd, '- old\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
// Good patch: updates the existing line.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'a-good.patch'),
|
||||
buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
|
||||
);
|
||||
// Stale patch: context expects something that doesn't exist.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'b-stale.patch'),
|
||||
buildUpdatePatch(memoryMd, '- never existed\n', '- attempted\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'private',
|
||||
);
|
||||
|
||||
// Any failure → success=false so the dialog keeps the inbox entry
|
||||
// visible. (The successful sub-patches were already removed from disk;
|
||||
// the next listing will surface only the failures for retry.)
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/applied 1 of 2/i);
|
||||
expect(result.message).toMatch(/b-stale\.patch/);
|
||||
|
||||
// Good patch committed and removed; stale patch stays in inbox.
|
||||
await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'a-good.patch')),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'b-stale.patch')),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('dismiss with relativePath = kind removes all source patches', async () => {
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'a.patch'),
|
||||
buildCreationPatch(path.join(memoryTempDir, 'a.md'), 'a\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'b.patch'),
|
||||
buildCreationPatch(path.join(memoryTempDir, 'b.md'), 'b\n'),
|
||||
);
|
||||
|
||||
const result = await dismissInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'private',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toMatch(/dismissed 2/i);
|
||||
await expect(fs.access(path.join(patchDir, 'a.patch'))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(patchDir, 'b.patch'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects global patches that target anything other than ~/.gemini/GEMINI.md', async () => {
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
|
||||
// memory.md (lowercase) is NOT a valid global memory file.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'wrong-name.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'memory.md'),
|
||||
'Should be rejected.\n',
|
||||
),
|
||||
);
|
||||
|
||||
// Sibling .md files in ~/.gemini/ are also not allowed.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'sibling.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'notes.md'),
|
||||
'Should be rejected.\n',
|
||||
),
|
||||
);
|
||||
|
||||
// Non-memory files (settings, credentials) must stay off-limits.
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'settings.patch'),
|
||||
buildCreationPatch(
|
||||
path.join(globalMemoryDir, 'settings.json'),
|
||||
'{"foo": 1}\n',
|
||||
),
|
||||
);
|
||||
|
||||
for (const fileName of [
|
||||
'wrong-name.patch',
|
||||
'sibling.patch',
|
||||
'settings.patch',
|
||||
]) {
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'global',
|
||||
fileName,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/outside the global memory root/i);
|
||||
}
|
||||
|
||||
// None of the bogus targets were created.
|
||||
for (const orphan of ['memory.md', 'notes.md', 'settings.json']) {
|
||||
await expect(
|
||||
fs.access(path.join(globalMemoryDir, orphan)),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid memory patch paths', async () => {
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'../MEMORY.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Invalid memory patch path.');
|
||||
});
|
||||
|
||||
it('rejects a creation patch whose target already exists', async () => {
|
||||
const target = path.join(memoryTempDir, 'MEMORY.md');
|
||||
await fs.writeFile(target, 'pre-existing\n');
|
||||
|
||||
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
|
||||
await fs.mkdir(patchDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(patchDir, 'MEMORY.patch'),
|
||||
buildCreationPatch(target, 'replacement\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxMemoryPatch(
|
||||
patchConfig,
|
||||
'private',
|
||||
'MEMORY.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/declares a new file/);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
|
||||
'pre-existing\n',
|
||||
);
|
||||
await expect(
|
||||
fs.access(path.join(patchDir, 'MEMORY.patch')),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveInboxSkill', () => {
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user