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
27 changes: 22 additions & 5 deletions docs/users/features/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,32 @@ Don't include things Qwen can figure out by reading your code. QWEN.md works bes

### Where to create QWEN.md

| File | Who it applies to |
| ----------------------------- | --------------------------------------------- |
| `~/.qwen/QWEN.md` | You, across all your projects |
| `QWEN.md` in the project root | Your whole team (commit it to source control) |
| File | Who it applies to |
| ----------------------------- | ------------------------------------------------ |
| `~/.qwen/QWEN.md` | You, across all your projects |
| `QWEN.md` in the project root | Your whole team (commit it to source control) |
| `.qwen/QWEN.local.md` | Only you, only in this project (keep out of git) |

You can have both. Qwen loads all QWEN.md files it finds when you start a session — your personal one plus any in the project.
You can have any combination of these. Qwen loads all of them when you start a session.

If your repository already has an `AGENTS.md` file for other AI tools, Qwen reads that too. No need to duplicate instructions.

#### When to use `.qwen/QWEN.local.md`

Use it for **project-specific but personal** instructions — things that belong to this project but shouldn't be shared with the team:

- Your own cluster ID, container registry namespace, or cloud account
- A personal debug command that hardcodes your local environment
- Notes you want Qwen to know about your work-in-progress, but not commit

It loads **after** the shared project `QWEN.md`, so your local instructions can supplement or override the team's.

**You must gitignore it yourself.** Although `.qwen/` is often treated as a local directory, qwen-code does not generate a `.gitignore` for you, and some projects commit `.qwen/settings.json`. Add this line to your `.gitignore` (or to your global git ignore):

```
.qwen/QWEN.local.md
```

### Generate one automatically with `/init`

Run `/init` and Qwen will analyze your codebase to create a starter QWEN.md with build commands, test instructions, and conventions it finds. If one already exists, it suggests additions instead of overwriting.
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/memory/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@

export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
export const AGENT_CONTEXT_FILENAME = 'AGENTS.md';
/**
* Per-developer, project-scoped context file. Anchored at
* `<projectRoot>/.qwen/QWEN.local.md`. Intended to be gitignored so each
* developer can keep personal instructions (local cluster IDs, account
* names, paths) without polluting the shared project `QWEN.md` or the
* global `~/.qwen/QWEN.md`.
*
* Unlike `DEFAULT_CONTEXT_FILENAME` / `AGENT_CONTEXT_FILENAME`, this name is
* NOT part of the hierarchical upward-search list — it is loaded from a
* single fixed slot, after all other project-level context files, so it can
* supplement or override shared instructions.
*
* Project root is the nearest ancestor containing a `.git` directory OR a
* `.git` file (the latter marks git worktrees and submodules). If no
* project root can be found, the slot is skipped — the loader does NOT
* fall back to cwd, because that would turn a "single fixed slot" into a
* per-cwd file and (when cwd is the home directory) would collide with
* the global Qwen dir at `~/.qwen/`.
*/
export const LOCAL_CONTEXT_FILENAME = 'QWEN.local.md';
export const MEMORY_SECTION_HEADER = '## Qwen Added Memories';

// This variable will hold the currently configured filename for context files.
Expand Down
324 changes: 324 additions & 0 deletions packages/core/src/utils/memoryDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,4 +536,328 @@ describe('loadServerHierarchicalMemory', () => {
expect(parentOccurrences).toBe(1);
expect(childOccurrences).toBe(1);
});

describe('QWEN.local.md (project-local context file)', () => {
// The local-context-file slot is anchored at `<projectRoot>/.qwen/`, where
// projectRoot is the nearest ancestor containing a `.git` directory OR a
// `.git` file (the latter is how git worktrees and submodules are marked).
// Most tests in this block use the directory form; a few below cover the
// file form and the no-project-root case explicitly.
beforeEach(async () => {
await createEmptyDir(path.join(projectRoot, '.git'));
});

it('loads .qwen/QWEN.local.md from project root when present', async () => {
const localFile = await createTestFile(
path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'),
'local context content',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
);

expect(result.fileCount).toBe(1);
expect(result.memoryContent).toContain(
`--- Context from: ${path.relative(cwd, localFile)} ---\nlocal context content`,
);
});

it('orders QWEN.local.md after the project-root QWEN.md', async () => {
const projectFile = await createTestFile(
path.join(projectRoot, DEFAULT_CONTEXT_FILENAME),
'shared project context',
);
const localFile = await createTestFile(
path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'),
'local override',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
);

expect(result.fileCount).toBe(2);
const projectIdx = result.memoryContent.indexOf(
path.relative(cwd, projectFile),
);
const localIdx = result.memoryContent.indexOf(
path.relative(cwd, localFile),
);
expect(projectIdx).toBeGreaterThanOrEqual(0);
expect(localIdx).toBeGreaterThan(projectIdx);
});

it('orders QWEN.local.md after upward-traversed CWD QWEN.md', async () => {
const projectFile = await createTestFile(
path.join(projectRoot, DEFAULT_CONTEXT_FILENAME),
'project root memory',
);
const cwdFile = await createTestFile(
path.join(cwd, DEFAULT_CONTEXT_FILENAME),
'cwd memory',
);
const localFile = await createTestFile(
path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'),
'local memory',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
);

expect(result.fileCount).toBe(3);
const projectIdx = result.memoryContent.indexOf(
path.relative(cwd, projectFile),
);
const cwdIdx = result.memoryContent.indexOf(path.relative(cwd, cwdFile));
const localIdx = result.memoryContent.indexOf(
path.relative(cwd, localFile),
);
expect(projectIdx).toBeGreaterThanOrEqual(0);
expect(cwdIdx).toBeGreaterThan(projectIdx);
expect(localIdx).toBeGreaterThan(cwdIdx);
});

it('silently ignores absent .qwen/QWEN.local.md', async () => {
await createTestFile(
path.join(projectRoot, DEFAULT_CONTEXT_FILENAME),
'project content',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
);

expect(result.fileCount).toBe(1);
expect(result.memoryContent).toContain('project content');
expect(result.memoryContent).not.toContain('QWEN.local.md');
});

it('does not load QWEN.local.md from untrusted workspaces', async () => {
await createTestFile(
path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'),
'local content',
);

const { fileCount, memoryContent } = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
false, // untrusted
);

expect(fileCount).toBe(0);
expect(memoryContent).not.toContain('local content');
});

it('does not load QWEN.local.md in explicit-only mode', async () => {
await createTestFile(
path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'),
'local content',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
'tree',
[],
{ explicitOnly: true },
);

expect(result.fileCount).toBe(0);
expect(result.memoryContent).not.toContain('local content');
});

it('does not search .qwen/QWEN.local.md in CWD subdirectories', async () => {
// A `.qwen/QWEN.local.md` placed inside a nested directory (not the
// project root) must NOT be picked up — the slot is single, fixed,
// and lives at <projectRoot>/.qwen/QWEN.local.md.
await createTestFile(
path.join(cwd, QWEN_DIR, 'QWEN.local.md'),
'misplaced local content',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
);

expect(result.fileCount).toBe(0);
expect(result.memoryContent).not.toContain('misplaced local content');
});

it('loads QWEN.local.md even when no project QWEN.md exists', async () => {
const localFile = await createTestFile(
path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'),
'standalone local',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
);

expect(result.fileCount).toBe(1);
expect(result.memoryContent).toContain(
`--- Context from: ${path.relative(cwd, localFile)} ---\nstandalone local`,
);
});

it('loads QWEN.local.md when project root is marked by a .git FILE (worktree / submodule layout)', async () => {
// Git worktrees and submodules mark the repo root with a `.git` file
// (containing `gitdir: <path>`), not a `.git` directory. The loader
// must treat that as a valid project root, otherwise `<cwd>` is used
// as a silent fallback and the documented project-root slot never
// loads. Replace the directory created by beforeEach with a file.
await fsPromises.rm(path.join(projectRoot, '.git'), {
recursive: true,
force: true,
});
await fsPromises.writeFile(
path.join(projectRoot, '.git'),
'gitdir: /elsewhere/worktrees/feature/.git\n',
);

const localFile = await createTestFile(
path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'),
'worktree local',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
);

expect(result.fileCount).toBe(1);
expect(result.memoryContent).toContain(
`--- Context from: ${path.relative(cwd, localFile)} ---\nworktree local`,
);
});

it('skips QWEN.local.md when no project root can be found (no .git ancestor)', async () => {
// Without a project root, falling back to cwd would silently turn the
// single fixed slot into a per-cwd file — opposite of the design.
// Pin the "skip" behavior so a future regression doesn't reintroduce
// the fallback.
await fsPromises.rm(path.join(projectRoot, '.git'), {
recursive: true,
force: true,
});

await createTestFile(
path.join(cwd, QWEN_DIR, 'QWEN.local.md'),
'cwd-anchored local that must not load',
);
await createTestFile(
path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'),
'projectRoot-anchored local that must not load either',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
);

expect(result.fileCount).toBe(0);
expect(result.memoryContent).not.toContain(
'cwd-anchored local that must not load',
);
expect(result.memoryContent).not.toContain(
'projectRoot-anchored local that must not load either',
);
});

it('skips QWEN.local.md when cwd === homedir without .git (avoids global-dir collision)', async () => {
// When cwd is the home directory and there is no `.git` there, the
// would-be slot path resolves to `<homedir>/.qwen/QWEN.local.md` —
// i.e. inside the GLOBAL Qwen dir. Loading that as a project-local
// override is wrong: there is no project. Pin the "skip" behavior.
await fsPromises.rm(path.join(projectRoot, '.git'), {
recursive: true,
force: true,
});
await createTestFile(
path.join(homedir, QWEN_DIR, 'QWEN.local.md'),
'do not promote this to project-local',
);

const result = await loadServerHierarchicalMemory(
homedir, // cwd === homedir
[],
new FileDiscoveryService(homedir),
[],
DEFAULT_FOLDER_TRUST,
);

// Allowed: global QWEN.md / AGENTS.md in ~/.qwen/ may still load via
// the existing global-discovery path. The assertion here is narrow —
// the LOCAL slot specifically must not have been loaded.
expect(result.memoryContent).not.toContain(
'do not promote this to project-local',
);
});

it('dedupes when an extension registers the local slot path explicitly', async () => {
// The hierarchical scan iterates `getAllGeminiMdFilenames()`
// (QWEN.md / AGENTS.md) and never produces a `QWEN.local.md` path,
// so the dedup guard in the slot loader looks unreachable in
// production paths. It IS reachable, though, via
// `extensionContextFilePaths`: an extension may register the slot
// path explicitly, in which case the hierarchical scan picks it up
// via the extension-paths append. The dedup guard prevents the
// slot loader from then appending the same file a second time
// (double content + inflated fileCount). Pin that behavior.
const localFile = await createTestFile(
path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'),
'slot content only once',
);

const result = await loadServerHierarchicalMemory(
cwd,
[],
new FileDiscoveryService(projectRoot),
[localFile], // extension explicitly registers the slot path
DEFAULT_FOLDER_TRUST,
);

expect(result.fileCount).toBe(1);
const occurrences = (
result.memoryContent.match(/slot content only once/g) ?? []
).length;
expect(occurrences).toBe(1);
});
});
});
Loading
Loading