Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions docs/design/2026-07-25-pinned-memory-protection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Pinned Managed-Memory Protection

## Problem

Managed auto-memory recursively discovers valid markdown topics below the
project and user memory roots, subject to the existing index limits. Automatic
extraction and Dream consolidation agents can write or edit paths inside their
allowed memory roots, so a hand-curated file can be overwritten or consolidated
like an automatically generated memory.

The recursive scanner already discovers valid files below `pinned/`; the
missing behavior is deterministic mutation protection during automated memory
maintenance.

## Chosen design

Treat a top-level `pinned/` directory inside a managed-memory root as protected
from automatic-extraction mutation and excluded from Dream consolidation:

- Keep valid pinned documents readable to normal memory recall and discoverable
by the existing indexer under its normal limits.
- Deny automatic extraction and forked Dream `write_file` and `edit` operations
when the requested path is lexically below `pinned/`.
- Match the reserved top-level directory name case-insensitively so the
deny-list cannot fail open on case-insensitive filesystems.
- Also deny aliases that resolve through a symlink into `pinned/`.
- Keep the existing read-only shell gate, which already rejects `rm` and every
other mutating shell command.
- Teach the automatic extraction and Dream prompts to leave pinned documents
unchanged and avoid intentionally removing their existing index entries,
subject to normal index limits.

The path check compares both literal and resolved paths case-insensitively.
Literal containment protects `pinned/` even when that directory is itself a
symlink. Resolved containment prevents a writable-looking path elsewhere in
memory from symlinking back into `pinned/`.

Protection is an explicit option on the existing memory-scoped agent
configuration and is enabled by the automatic extraction and forked Dream
planners. This covers post-session extraction, scheduled Dream, and callers of
the workspace-memory Dream endpoint. Explicit remember operations retain their
current behavior.

## Scope boundaries

- No scanner or indexer production change: recursive discovery already handles
project and user `pinned/` documents with the existing frontmatter schema.
- No new frontmatter field and no automatic creation of the directory.
- No `/memory` UI indicator.
- Explicit `/forget` requests keep their current behavior.
- This path-based boundary does not detect pre-existing hard-link aliases to
pinned files. Automatic memory workers cannot create them with `write_file`
or `edit`, and their read-only shell policy blocks `ln`; a stronger threat
model would require a separate inode-based policy.
- The visible `/dream` slash-command turn receives the shared skip prompt rule,
but does not gain a deterministic tool gate in this change. The slash command
executes on the main Agent, which has no existing per-turn permission
override; adding one would be a separate cross-surface permission design.
- Forked Dream remains project-memory-only because its existing scoped
configuration excludes the global user-memory root.
- Automatic extraction continues to cover both project and global user-memory
roots, so both top-level `pinned/` directories receive the same protection.

## Files affected

- `packages/core/src/memory/paths.ts`
- `packages/core/src/memory/memory-scoped-agent-config.ts`
- `packages/core/src/memory/dreamAgentPlanner.ts`
- `packages/core/src/memory/extractionAgentPlanner.ts`
- Collocated memory permission, prompt, and index tests
- `docs/users/features/memory.md`

## Open question

Whether the visible `/dream` slash command must receive the same deterministic
gate remains a maintainer scope decision. If required, it should be implemented
as a general per-turn permission override rather than by mutating the
session-wide permission manager around one asynchronous tool loop.
27 changes: 27 additions & 0 deletions docs/users/features/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,33 @@ Auto-memory files live at `~/.qwen/projects/<project>/memory/`. All branches of

Everything saved is plain markdown — you can open, edit, or delete any file at any time.

#### Pinned memory

Put hand-curated documents that automatic memory maintenance should preserve
under `pinned/` in a managed-memory directory, for example
`~/.qwen/projects/<project>/memory/pinned/architecture.md` or
`~/.qwen/memories/pinned/preferences.md`. Use the same frontmatter as other
memory documents. Valid pinned files are readable by Qwen and are included the
next time `MEMORY.md` is rebuilt, under the same size and file-count limits as
other memory documents.

Only the top-level `pinned/` directory directly inside a managed-memory root is
protected; nested directories such as `memory/project/pinned/` are ordinary
writable memory. Automatic extraction and Dream workers match the reserved
directory name case-insensitively.

Automatic extraction is instructed to leave pinned records and their valid
index entries unchanged, while Dream is instructed to skip `pinned/` during
consolidation. Both automatic extraction and forked Dream workers, including
background cleanup, enforce the pinned-file boundary on their write and edit
tools, including paths that resolve through a symlink into `pinned/`; their
existing read-only shell policy blocks command-line deletion. You still control
these files directly and can remove them with an explicit `/forget` request.

> **Note:** The visible `/dream` slash command runs on the main Agent. It
> receives the same skip instruction, but does not yet receive the forked
> worker's deterministic per-turn tool gate.

### Periodic cleanup

Qwen periodically goes through its saved memories to remove duplicates and clean up outdated entries. This runs automatically in the background once a day after enough sessions have accumulated. You can trigger it manually with `/dream` if you want it to run now.
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/memory/dreamAgentPlanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { ForkedAgentResult } from '../utils/forkedAgent.js';
import { runForkedAgent } from '../utils/forkedAgent.js';
import { escapeShellArg, getShellConfiguration } from '../utils/shell-utils.js';
import {
AUTO_MEMORY_PINNED_DIRNAME,
getAutoMemoryRoot,
getUserAutoMemoryRoot,
clearAutoMemoryRootCache,
Expand Down Expand Up @@ -111,6 +112,20 @@ describe('dreamAgentPlanner', () => {
);
});

it('excludes pinned memories from consolidation', () => {
const prompt = buildConsolidationTaskPrompt(
path.join(tempDir, 'memory'),
path.join(tempDir, 'transcripts'),
);

expect(prompt).toContain('`pinned/`');
expect(prompt).toContain('Skip `pinned/` during Dream');
expect(prompt).toContain(
'Do not intentionally remove existing index entries for valid `pinned/` files',
);
expect(prompt).toContain('normal index limits still apply');
});

it('returns the forked agent result', async () => {
const mockResult: ForkedAgentResult = {
status: 'completed',
Expand Down Expand Up @@ -180,12 +195,34 @@ describe('dreamAgentPlanner', () => {
filePath: path.join(getAutoMemoryRoot(projectRoot), 'project.md'),
}),
).resolves.toBe('allow');
await expect(
pm.evaluate({
toolName: ToolNames.EDIT,
filePath: path.join(
getAutoMemoryRoot(projectRoot),
AUTO_MEMORY_PINNED_DIRNAME,
'architecture.md',
),
}),
).resolves.toBe('deny');
await expect(
pm.evaluate({
toolName: ToolNames.WRITE_FILE,
filePath: path.join(getUserAutoMemoryRoot(), 'user', 'a.md'),
}),
).resolves.toBe('deny');
// Pinned protection applies to write/edit; shell deletion is blocked by
// the pre-existing read-only shell policy.
await expect(
pm.evaluate({
toolName: ToolNames.SHELL,
command: `rm ${path.join(
getAutoMemoryRoot(projectRoot),
AUTO_MEMORY_PINNED_DIRNAME,
'architecture.md',
)}`,
}),
).resolves.toBe('deny');
});

it('throws when the agent fails', async () => {
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/memory/dreamAgentPlanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
} from '../utils/forkedAgent.js';
import * as path from 'node:path';
import { Storage } from '../config/storage.js';
import { AUTO_MEMORY_INDEX_FILENAME, getAutoMemoryRoot } from './paths.js';
import {
AUTO_MEMORY_INDEX_FILENAME,
AUTO_MEMORY_PINNED_DIRNAME,
getAutoMemoryRoot,
} from './paths.js';
import { ToolNames } from '../tools/tool-names.js';
import { escapeShellArg, getShellConfiguration } from '../utils/shell-utils.js';
import { createMemoryScopedAgentConfig } from './memory-scoped-agent-config.js';
Expand All @@ -24,7 +28,9 @@ const DREAM_AGENT_SYSTEM_PROMPT = `You are performing a managed memory dream —
Synthesize what you've learned recently into durable, well-organized memories so that future sessions can orient quickly.

Rules:
- Merge semantically duplicate entries — if the same fact appears in multiple files, consolidate into one file and delete the rest.
- Treat files under the top-level \`${AUTO_MEMORY_PINNED_DIRNAME}/\` directory as protected read-only records. Never modify, overwrite, rename, merge into, or delete them.
- Leave \`${AUTO_MEMORY_PINNED_DIRNAME}/\` out of consolidation analysis; do not list, read, or compare its files during Dream.
- Merge semantically duplicate entries among writable topic files — if the same fact appears in multiple writable files, consolidate into one file and delete the rest.
- Preserve all durable information; do not delete content that is still accurate.
- Fix contradicted or stale facts only when the evidence is clear from the existing memory content or recent transcript signal.
- Update the MEMORY.md index to accurately reflect surviving files.
Expand Down Expand Up @@ -56,6 +62,7 @@ export function buildConsolidationTaskPrompt(
'- List the memory directory to see what files exist',
`- Read \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\` to understand the current index`,
'- Skim topic subdirectories (`user/`, `project/`, `feedback/`, `reference/`)',
`- Skip \`${AUTO_MEMORY_PINNED_DIRNAME}/\` during Dream; do not list or read files there`,
'- If `logs/` or `sessions/` subdirectories exist, review recent entries there',
'',
'## Phase 2 — Gather recent signal',
Expand All @@ -73,6 +80,7 @@ export function buildConsolidationTaskPrompt(
'For each topic directory:',
'- Identify duplicate or near-duplicate `.md` files (same fact expressed differently)',
'- Merge duplicates: write the canonical version into one file, delete the redundant files',
`- Exclude \`${AUTO_MEMORY_PINNED_DIRNAME}/\` from duplicate, stale, and contradiction analysis; never use a pinned file as a merge target or deletion candidate`,
'- Fix stale or contradicted facts when clear from the existing content',
'- Convert relative dates (for example: "yesterday", "last week") to absolute dates when preserving them',
'',
Expand All @@ -81,6 +89,7 @@ export function buildConsolidationTaskPrompt(
`Update \`${memoryRoot}/${AUTO_MEMORY_INDEX_FILENAME}\` to reflect surviving files.`,
'Each entry: `- [Title](relative/path.md) — one-line hook`',
'Keep the index under roughly 200 lines and ~25KB.',
`Do not intentionally remove existing index entries for valid \`${AUTO_MEMORY_PINNED_DIRNAME}/\` files during consolidation; normal index limits still apply.`,
'Remove pointers to deleted, stale, wrong, or superseded files. Add pointers to any newly created files.',
'If an index line is too verbose, shorten it and move the detail back into the memory file itself.',
'',
Expand All @@ -101,6 +110,7 @@ export async function planManagedAutoMemoryDreamByAgent(
const scopedConfig = createMemoryScopedAgentConfig(config, projectRoot, {
allowShell: true,
includeUserMemory: false,
protectPinnedMemory: true,
});
const result = await runForkedAgent({
name: 'managed-auto-memory-dreamer',
Expand Down
92 changes: 91 additions & 1 deletion packages/core/src/memory/extractionAgentPlanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config } from '../config/config.js';
import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js';
import { scanAutoMemoryTopicDocuments } from './scan.js';
import { getAutoMemoryRoot, getUserAutoMemoryRoot } from './paths.js';
import {
AUTO_MEMORY_PINNED_DIRNAME,
getAutoMemoryRoot,
getUserAutoMemoryRoot,
} from './paths.js';
import { runForkedAgent, getCacheSafeParams } from '../utils/forkedAgent.js';
import { ToolNames } from '../tools/tool-names.js';

Expand Down Expand Up @@ -185,6 +189,92 @@ describe('runAutoMemoryExtractionByAgent', () => {
).toBe('deny');
});

it('protects pinned memory in both managed-memory scopes', async () => {
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
finalText: '',
filesTouched: [],
});

await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');

const call = vi.mocked(runForkedAgent).mock.calls[0]?.[0];
const permissionManager = call?.config.getPermissionManager?.();
expect(permissionManager).toBeDefined();
await expect(
permissionManager!.evaluate({
toolName: ToolNames.WRITE_FILE,
filePath: `/tmp/auto-memory/${AUTO_MEMORY_PINNED_DIRNAME}/architecture.md`,
}),
).resolves.toBe('deny');
await expect(
permissionManager!.evaluate({
toolName: ToolNames.EDIT,
filePath: `/tmp/auto-memory/${AUTO_MEMORY_PINNED_DIRNAME}/architecture.md`,
}),
).resolves.toBe('deny');
await expect(
permissionManager!.evaluate({
toolName: ToolNames.WRITE_FILE,
filePath: `/tmp/user-memory/${AUTO_MEMORY_PINNED_DIRNAME}/preferences.md`,
}),
).resolves.toBe('deny');
await expect(
permissionManager!.evaluate({
toolName: ToolNames.EDIT,
filePath: `/tmp/user-memory/${AUTO_MEMORY_PINNED_DIRNAME}/preferences.md`,
}),
).resolves.toBe('deny');
await expect(
permissionManager!.evaluate({
toolName: ToolNames.WRITE_FILE,
filePath: '/tmp/auto-memory/project/ordinary.md',
}),
).resolves.toBe('allow');
await expect(
permissionManager!.evaluate({
toolName: ToolNames.EDIT,
filePath: '/tmp/user-memory/user/ordinary.md',
}),
).resolves.toBe('allow');
await expect(
permissionManager!.evaluate({
toolName: ToolNames.WRITE_FILE,
filePath: `/tmp/auto-memory/project/${AUTO_MEMORY_PINNED_DIRNAME}/notes.md`,
}),
).resolves.toBe('allow');
await expect(
permissionManager!.evaluate({
toolName: ToolNames.EDIT,
filePath: `/tmp/auto-memory/${AUTO_MEMORY_PINNED_DIRNAME}-notes/notes.md`,
}),
).resolves.toBe('allow');
});

it('instructs the extraction agent to preserve pinned memory', async () => {
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
finalText: '',
filesTouched: [],
});

await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');

const call = vi.mocked(runForkedAgent).mock.calls[0]?.[0];
expect(call?.taskPrompt).toContain(
`top-level \`${AUTO_MEMORY_PINNED_DIRNAME}/\` directory`,
);
expect(call?.taskPrompt).toContain(
'You may read them to avoid duplicates, but never modify, overwrite, rename, merge into, or delete',
);
expect(call?.taskPrompt).toContain(
'Prefer updating an existing writable memory file',
);
expect(call?.taskPrompt).toContain(
'do not intentionally remove their valid entries from `MEMORY.md`',
);
});

it('throws when getCacheSafeParams returns null', async () => {
vi.mocked(getCacheSafeParams).mockReturnValue(null);
await expect(
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/memory/extractionAgentPlanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from './prompt.js';
import {
AUTO_MEMORY_INDEX_FILENAME,
AUTO_MEMORY_PINNED_DIRNAME,
getAutoMemoryRoot,
getUserAutoMemoryRoot,
} from './paths.js';
Expand Down Expand Up @@ -156,7 +157,8 @@ function buildTaskPrompt(
'- You have a limited turn budget. `edit` requires a prior `read_file` of the same file, so the efficient strategy is: first issue all reads in parallel for every file you might update; then issue all `write_file`/`edit` calls in parallel. Do not interleave reads and writes across multiple turns.',
'- You MUST only use content from the recent conversation history in your context plus the current managed memory files.',
'- Do not inspect repository code, git history, or unrelated files.',
'- Prefer updating an existing memory file over creating a duplicate. Check both directories for an existing entry before creating a new one.',
`- Treat files under the top-level \`${AUTO_MEMORY_PINNED_DIRNAME}/\` directory in either managed memory root as protected read-only records. You may read them to avoid duplicates, but never modify, overwrite, rename, merge into, or delete them, and do not intentionally remove their valid entries from \`${AUTO_MEMORY_INDEX_FILENAME}\`.`,
'- Prefer updating an existing writable memory file over creating a duplicate. Check both directories for an existing entry before creating a new one.',
'- Keep one durable memory per file under `user/`, `feedback/`, `project/`, or `reference/` inside the chosen directory.',
'',
'## How to save memories',
Expand Down Expand Up @@ -259,6 +261,7 @@ export async function runAutoMemoryExtractionByAgent(
const userMemoryRoot = getUserAutoMemoryRoot();
const scopedConfig = createMemoryScopedAgentConfig(config, projectRoot, {
allowShell: true,
protectPinnedMemory: true,
});

const result = await runForkedAgent({
Expand Down
Loading
Loading