Skip to content

feat(memory): project-scoped memory writes and .qwen/QWEN.local.md#4290

Closed
launchswitch wants to merge 10 commits into
QwenLM:mainfrom
launchswitch:feat/project-scoped-memory
Closed

feat(memory): project-scoped memory writes and .qwen/QWEN.local.md#4290
launchswitch wants to merge 10 commits into
QwenLM:mainfrom
launchswitch:feat/project-scoped-memory

Conversation

@launchswitch

@launchswitch launchswitch commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Two related memory features on the same branch (the second builds on the first):

1. Auto scope for project-scoped memory writes (#359)

Adds 'auto' scope to WriteContextFileScope so save_memory can write to the project-level context file (QWEN.md or AGENTS.md) when one exists at the project root, falling back to global ~/.qwen/ when neither exists. Prevents "memory bleed" between projects where project-specific facts leaked into unrelated sessions via the shared global file.

2. .qwen/QWEN.local.md support for personal instructions (#4091)

Add a project-local, gitignored context file at .qwen/QWEN.local.md — personal instructions that live inside the project tree but are never committed. Parallels Claude Code's CLAUDE.local.md.

Condition Result
scope='auto' + project QWEN.md exists Write to project QWEN.md
scope='auto' + project AGENTS.md exists (no QWEN.md) Write to project AGENTS.md
scope='auto' + .qwen/QWEN.local.md exists (no committed file) Write to .qwen/QWEN.local.md
scope='auto' + none exist Write to global ~/.qwen/QWEN.md
scope='local' Write to .qwen/QWEN.local.md (creates dir + gitignore)

Precedence order (later in prompt = higher priority): global → local → committed

Changes

  • writeContextFile.ts: Async resolveContextFilePath with 'auto' and 'local' scopes; ensureGitignoreEntry() helper for local file writes
  • memoryDiscovery.ts: Single fs.access() probe for local file at project root, spliced between global and committed paths
  • workspaceMemory.ts: Accepts 'auto' and 'local' in POST validation; GET route surfaces local file with scope: 'local'
  • status.ts: ServeContextFileScope extended to include 'local'
  • ignorePatterns.ts: Excludes .qwen/QWEN.local.md from file searches
  • const.ts / memory-config.ts: LOCAL_CONTEXT_FILENAME constant

Test plan

  • 75 tests pass: memoryDiscovery.test.ts (21), writeContextFile.test.ts (26), ignorePatterns.test.ts (28)
  • TypeScript compiles cleanly for both core and cli packages
  • Discovery: local file loaded, correctly ordered, skipped for untrusted/explicit-only
  • Write: scope='local' creates .qwen/ dir, manages .gitignore, no duplicate entries
  • Auto scope: falls back through committed → local → global

Closes #359
Closes #4091

🤖 Generated with Claude Code

launchswitch and others added 3 commits May 18, 2026 09:34
When scope='auto', writeWorkspaceContextFile checks if a project-level
context file (QWEN.md or AGENTS.md) exists at the project root. If one
is found, the memory is written there instead of the global ~/.qwen/
directory. If no project-level file exists, it falls back to global
(maintaining backward compatibility).

This addresses issue #359: memory bleed between projects caused by the
save_memory tool writing all facts to a single global QWEN.md file.

Changes:
- Add 'auto' to WriteContextFileScope type
- Add auto-detect logic in resolveContextFilePath
- Accept 'auto' in the POST /workspace/memory route
- 4 new tests covering: project QWEN.md, project AGENTS.md, global
  fallback, and QWEN.md preference over AGENTS.md

Behavior:
- scope='auto' + project QWEN.md exists  -> write to project QWEN.md
- scope='auto' + project AGENTS.md exists -> write to project AGENTS.md
- scope='auto' + neither exists          -> write to global ~/.qwen/
- scope='workspace' and 'global'         -> unchanged from before
Replace synchronous statSync with async fs.stat in resolveContextFilePath
to avoid blocking the event loop in the serve daemon. Use explicit ENOENT
check instead of bare catch, add afterEach for test isolation, use Set
for scope validation, and narrow the scope type at the route handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add project-local, gitignored context file at .qwen/QWEN.local.md that
lives inside the project tree but is never committed. Parallels Claude
Code's CLAUDE.local.md pattern — committed QWEN.md/AGENTS.md are for
shared team instructions, the local file is for personal overrides.

Separate discovery pass (Option B) with dedicated 'local' scope:
- Read path: single fs.access() probe at project root, spliced between
  global and committed paths in the prompt (committed wins on conflict)
- Write path: new scope='local' resolves to .qwen/QWEN.local.md, with
  auto gitignore management via ensureGitignoreEntry()
- Daemon routes: GET surfaces local file with scope='local', POST
  accepts the new scope value
- Ignore patterns: excludes .qwen/QWEN.local.md from file searches

Closes #4091

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@launchswitch launchswitch changed the title feat(memory): add 'auto' scope for project-scoped memory writes feat(memory): project-scoped memory writes and .qwen/QWEN.local.md May 18, 2026
try {
const result = await writeWorkspaceContextFile({
scope,
scope: scope as WriteContextFileScope,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The POST handler now accepts 'local' and 'auto' scopes, and the raw scope value from the request body is passed directly into memory_changed events (line 218). However, DaemonMemoryChangedData.scope in packages/sdk-typescript/src/daemon/events.ts:149 is typed as 'workspace' | 'global', and the isMemoryChangedData type guard at line 1071 only checks for those two values. Events emitted with 'local' or 'auto' scope will fail the SDK type guard, causing SDK consumers to silently drop or misclassify these events.

Fix: Update DaemonMemoryChangedData.scope to include 'local' | 'auto' and update the isMemoryChangedData validator accordingly in events.ts.

— qwen-latest-series-invite-beta-v28 via Qwen Code /review


// Ensure the local context file is gitignored before writing.
if (options.scope === 'local') {
await ensureGitignoreEntry(options.projectRoot);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] ensureGitignoreEntry is only called when scope === 'local'. When scope === 'auto' resolves to the local file path (.qwen/QWEN.local.md — because no committed QWEN.md/AGENTS.md exists), the gitignore entry is not ensured. If the local file was created out-of-band (manually or by another tool), writes via auto scope could leave it unprotected from accidental commits.

Suggested change
await ensureGitignoreEntry(options.projectRoot);
if (filePath === path.join(options.projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME)) {
await ensureGitignoreEntry(options.projectRoot);
}

— qwen-latest-series-invite-beta-v28 via Qwen Code /review


export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
export const AGENT_CONTEXT_FILENAME = 'AGENTS.md';
export const LOCAL_CONTEXT_FILENAME = 'QWEN.local.md';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] LOCAL_CONTEXT_FILENAME is duplicated verbatim in packages/core/src/tools/memory-config.ts:15. These two files are complete duplicates (identical constants, state, and functions). All runtime imports use memory/const.ts; memory-config.ts has zero importers (only used in a vi.mock() call). Adding another constant to both files means another line that must be kept in sync manually — risk of silent drift.

Fix: Consolidate — have memory-config.ts re-export from const.ts, or remove the duplicate entirely.

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

Take upstream's re-export for cli/serve/status.ts and update the
canonical source at acp-bridge/src/status.ts with the 'local' scope.
1. [Critical] Update DaemonMemoryChangedData.scope in SDK events.ts
   to include 'local' | 'auto' and update isMemoryChangedData type
   guard accordingly — prevents SDK consumers from silently dropping
   memory_changed events with new scope values.

2. [Suggestion] Ensure gitignore entry when 'auto' scope resolves to
   .qwen/QWEN.local.md — previously only protected explicit
   scope='local' writes, leaving auto-resolved local files unprotected.

3. [Suggestion] Consolidate duplicate constants: memory-config.ts now
   re-exports from the canonical const.ts instead of maintaining a
   verbatim copy, preventing future drift.

Also resolves merge conflict with upstream/main by accepting the
acp-bridge status.ts re-export and adding 'local' to the canonical
ServeContextFileScope type in acp-bridge/src/status.ts.
@launchswitch launchswitch requested a review from wenshao May 21, 2026 01:50
// non-global path and insert local before it so the final order
// is: global → local → committed.
const insertIdx = filePaths.findIndex(
(p) => !p.startsWith(globalQwenDir),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] findIndex assumes filePaths is partitioned as [global..., committed...], but getGeminiMdFilePathsInternalForEachDir iterates per-filename, producing an interleaved order with the default config [QWEN.md, AGENTS.md]: [global/QWEN.md, workspace/QWEN.md, global/AGENTS.md, workspace/AGENTS.md]. findIndex(first non-global) returns index 1, so the local file is spliced between global/QWEN.md and workspace/QWEN.md — placing it before global/AGENTS.md. This violates the stated "global → local → committed" priority: instructions in ~/.qwen/AGENTS.md incorrectly override .qwen/QWEN.local.md.

Suggested change
(p) => !p.startsWith(globalQwenDir),
const globalQwenDir = Storage.getGlobalQwenDir();
// filePaths is interleaved per-filename (e.g. [global/QWEN.md,
// workspace/QWEN.md, global/AGENTS.md, workspace/AGENTS.md]).
// Find the last global-scoped path and insert local after it so
// the final order is: global → local → committed.
let insertIdx = -1;
for (let i = filePaths.length - 1; i >= 0; i--) {
if (filePaths[i].startsWith(globalQwenDir)) {
insertIdx = i + 1;
break;
}
}
if (insertIdx === -1) {
filePaths.unshift(localPath);
} else {
filePaths.splice(insertIdx, 0, localPath);
}

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

filePath ===
path.join(options.projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME)
) {
await ensureGitignoreEntry(options.projectRoot);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Two issues with ensureGitignoreEntry placement:

  1. Error propagation: on EACCES, EROFS, or any non-ENOENT .gitignore error, the exception kills the entire memory write — even though the memory file itself could be written successfully. A read-only .gitignore prevents all local-scope memory writes.

  2. Ordering: this runs before the whitespace-only no-op check inside runWrite (line ~204), which states "must not reach the filesystem at all." A whitespace-only POST with scope: 'local' still modifies .gitignore despite the no-op contract.

Suggested change
await ensureGitignoreEntry(options.projectRoot);
try {
await ensureGitignoreEntry(options.projectRoot);
} catch (err) {
logger.warn(`Failed to update .gitignore for local context file: ${err}`);
}

For the ordering issue, consider also moving this call inside runWrite after the whitespace guard, or short-circuiting the whitespace check before calling ensureGitignoreEntry.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

expect(qwenContent).toContain('- which one?');
});

it('falls back to local file when no committed context exists', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This test verifies result.filePath and file content but never asserts that .gitignore was updated with the local context file entry. The production code at writeContextFile.ts:162-166 has a path-based guard to trigger ensureGitignoreEntry when auto resolves to local — this is entirely untested. A regression in the path comparison would silently skip the gitignore update.

Suggested change
it('falls back to local file when no committed context exists', async () => {
it('falls back to local file when no committed context exists', async () => {
const localPath = path.join(workspace, QWEN_DIR, LOCAL_CONTEXT_FILENAME);
await fs.mkdir(path.dirname(localPath), { recursive: true });
await fs.writeFile(localPath, '# local\n', 'utf8');
const result = await writeWorkspaceContextFile({
scope: 'auto',
mode: 'append',
content: '- auto local entry',
projectRoot: workspace,
});
expect(result.filePath).toBe(localPath);
const written = await fs.readFile(localPath, 'utf8');
expect(written).toContain('- auto local entry');
const gitignore = await fs.readFile(path.join(workspace, '.gitignore'), 'utf8');
expect(gitignore).toContain(`${QWEN_DIR}/${LOCAL_CONTEXT_FILENAME}`);
});

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

// Mock the memoryTool module
vi.mock('../memory/const.js', () => ({
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md', 'AGENTS.md']),
LOCAL_CONTEXT_FILENAME: 'QWEN.local.md',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The mock provides LOCAL_CONTEXT_FILENAME: 'QWEN.local.md' and QWEN_DIR: '.qwen', but the existing includeDynamicPatterns test (line ~101-114) only asserts **/GEMINI.md and **/AGENTS.md. The new pattern added at ignorePatterns.ts:170 is effectively untested.

Consider adding assertions to the includeDynamicPatterns test:

expect(patternsWithDynamic).toContain('**/.qwen/QWEN.local.md');
expect(patternsWithoutDynamic).not.toContain('**/.qwen/QWEN.local.md');

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

4. [Critical] Fix local-file insertion ordering in memoryDiscovery.
   getGeminiMdFilePathsInternalForEachDir produces an interleaved list
   ([global/QWEN.md, workspace/QWEN.md, global/AGENTS.md, ...]), so the
   previous "insert before first non-global" stranded later global
   entries behind the local file. Now insert after the LAST global path
   so every global file stays ahead of local (global → local → committed).

5. [Suggestion] Harden gitignore handling in writeContextFile. Move the
   ensureGitignoreEntry call into runWrite after the whitespace no-op
   guard (a no-op append no longer touches .gitignore) and wrap it so an
   EACCES/EROFS failure degrades to a logged warning instead of aborting
   the memory write.

6. [Suggestion] Assert .gitignore is updated in the auto→local fallback
   test, covering the path-based guard.

7. [Suggestion] Assert **/.qwen/QWEN.local.md is excluded in the dynamic
   pattern tests; add an interleaved-filename regression test for the
   ordering fix in #4.

https://claude.ai/code/session_01JY4Y7Mq2Uddd8QKFt86ZuH
@launchswitch

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @wenshao! All seven items are now addressed across two commits (fce7655 and bfe2476):

# Severity Item Fix
1 Critical SDK type guard drops local/auto scope events Addressed in fce7655 — updated DaemonMemoryChangedData.scope and isMemoryChangedData validator.
2 Suggestion Duplicate LOCAL_CONTEXT_FILENAME across two files Addressed in fce7655 — consolidated, memory-config.ts re-exports from const.ts.
3 Suggestion scope: 'auto' resolving to local path skips gitignore Addressed in fce7655 — added path-based guard so auto→local triggers ensureGitignoreEntry.
4 Critical Interleaved filenames break insertion ordering Fixed in bfe2476 — now scans for the last global-scoped path and inserts after it, so the order is always global → local → committed. Added a regression test with interleaved filenames that fails against the old logic.
5 Suggestion ensureGitignoreEntry errors abort the write + runs before no-op guard Fixed in bfe2476 — moved into runWrite after the whitespace no-op guard, wrapped in try/catch so EACCES/EROFS degrades to a warning.
6 Suggestion Auto→local fallback test missing gitignore assertion Fixed in bfe2476 — test now asserts .gitignore contains the local file entry.
7 Suggestion Dynamic-pattern test missing **/.qwen/QWEN.local.md assertions Fixed in bfe2476 — added assertions for both include and exclude paths, plus the interleaved-ordering regression test for #4.

All 76 tests pass, tsc --noEmit and lint are clean.

@launchswitch launchswitch requested a review from wenshao May 21, 2026 12:32

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (lines not in diff, posted here):

[Suggestion] SSE event emits unresolved scope: 'auto' (workspaceMemory.ts:217): The memory_changed event emits the raw scope from the request body instead of the resolved scope. SDK clients that maintain per-scope caches cannot determine which cache to invalidate. Fix: have writeWorkspaceContextFile return resolvedScope: 'workspace' | 'local' | 'global' and use it in the event payload.

[Suggestion] HTTP test coverage gaps (workspaceMemory.test.ts): No POST test with scope='local', no POST test with scope='auto' (the fallback chain is untested via HTTP), no GET test for the local file probe in collectWorkspaceMemoryStatus. Additionally, isMemoryChangedData type guard tests only validate 'workspace''local' and 'auto' are untested.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

} catch (err) {
if (!isEnoent(err)) throw err;
}
return path.join(Storage.getGlobalQwenDir(), filename);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] auto scope silently falls back to global ~/.qwen/QWEN.md when no project-level context file exists. Combined with mode: 'replace', this wipes all global memories across every workspace with no confirmation, no backup, and no resolvedScope signal in the response or SSE event (which carries scope: 'auto'). Even with append, project-specific content leaks into every future session for every project — a cross-project data contamination vector.

Suggested change
return path.join(Storage.getGlobalQwenDir(), filename);
// Require explicit scope='global' for global writes; auto should
// create .qwen/QWEN.local.md instead of falling through to global.
const localPath = path.join(projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME);
try {
await ensureGitignoreEntry(projectRoot);
} catch { /* degrade gracefully */ }
return localPath;

Alternatively, refuse mode: 'replace' when auto resolves to global, or require explicit scope: 'global' for global writes. At minimum, log at warn level when auto resolves to global.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

const gitignorePath = path.join(projectRoot, '.gitignore');

try {
const existing = await fs.readFile(gitignorePath, 'utf8');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] fs.readFile and fs.writeFile on .gitignore follow symlinks without any O_NOFOLLOW or lstat pre-check. A malicious repository can ship .gitignore as a symlink pointing to ~/.bashrc, ~/.config/git/config, or any other user-writable file. When the first scope='local' (or auto→local) memory write triggers this function, .qwen/QWEN.local.md\n is appended to the symlink target, corrupting it.

Suggested change
const existing = await fs.readFile(gitignorePath, 'utf8');
try {
const lstat = await fs.lstat(gitignorePath);
if (lstat.isSymbolicLink()) {
logger.warn(
`.gitignore at ${gitignorePath} is a symlink; skipping gitignore update.`,
);
return;
}
} catch { /* ENOENT — create-new-file path below handles it */ }
try {
const existing = await fs.readFile(gitignorePath, 'utf8');

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

}
filePaths.splice(lastGlobalIdx + 1, 0, localPath);
logger.debug(`Found local context file: ${localPath}`);
} catch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Bare catch {} swallows ALL errors from fs.access, including EACCES, EPERM, and EIO — not just ENOENT. When .qwen/QWEN.local.md exists but is unreadable (wrong permissions, SELinux, macOS quarantine), the file is silently skipped with zero diagnostic output. The HTTP GET route (collectWorkspaceMemoryStatus) correctly distinguishes ENOENT from other errors and surfaces errorKind: 'stat_failed'; the discovery path should do the same.

Suggested change
} catch {
} catch (err) {
if (!isEnoent(err)) {
logger.warn(
`Could not read local context file ${localPath}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

files.push(...workspaceFiles);

// Probe for .qwen/QWEN.local.md at the workspace root.
const localPath = path.join(boundWorkspace, QWEN_DIR, LOCAL_CONTEXT_FILENAME);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Write path uses boundWorkspace (daemon's raw bound directory) while the read path in loadServerHierarchicalMemory uses findProjectRoot(resolvedCwd) (git root). In a monorepo where the daemon is bound to a subdirectory (e.g. /repo/packages/foo but git root is /repo), the POST route writes .qwen/QWEN.local.md to the subdirectory while the discovery path probes the git root's .qwen/ directory. Memory writes silently disappear from the prompt — the POST reports success with a filePath, GET shows the file exists, but the LLM never sees it.

Suggested change
const localPath = path.join(boundWorkspace, QWEN_DIR, LOCAL_CONTEXT_FILENAME);
// Resolve project root via findProjectRoot to match the discovery path.
const resolvedCwd = path.resolve(boundWorkspace);
const foundRoot = await findProjectRoot(resolvedCwd);
const effectiveRoot = foundRoot ?? resolvedCwd;
const localPath = path.join(effectiveRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME);

— qwen-latest-series-invite-beta-v34 via Qwen Code /review


// Compute effective project root for local file probe and rules.
const resolvedCwd = path.resolve(currentWorkingDirectory);
const foundRoot = await findProjectRoot(resolvedCwd);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] findProjectRoot(resolvedCwd) is called here at line 381 and again inside getGeminiMdFilePathsInternalForEachDir at line 194 — both with the same argument. Each call performs an fs.lstat('.git') walk up the directory tree, doubling the syscall count on every loadServerHierarchicalMemory invocation.

Either have getGeminiMdFilePathsInternal return the discovered project root alongside file paths ({ filePaths, projectRoot }), or pass effectiveRoot as an optional parameter to skip the inner traversal.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

// rather than aborting the memory write itself.
if (
options.scope === 'local' ||
filePath ===

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] path.join(root, QWEN_DIR, LOCAL_CONTEXT_FILENAME) is hand-written in 5 places across 3 files (here at lines 221, 295, 317, plus workspaceMemory.ts:351 and memoryDiscovery.ts:389). If QWEN_DIR or LOCAL_CONTEXT_FILENAME changes, every call site must be updated in lockstep.

Extract a shared helper into const.ts:

export function getLocalContextFilePath(projectRoot: string): string {
  return path.join(projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME);
}

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

path.join(options.projectRoot, QWEN_DIR, LOCAL_CONTEXT_FILENAME)
) {
try {
await ensureGitignoreEntry(options.projectRoot);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] ensureGitignoreEntry modifies .gitignore before fs.mkdir (line 236) and fs.writeFile (line 239). If the subsequent write fails (EACCES, ENOSPC), .gitignore has already been mutated with the .qwen/QWEN.local.md entry but no actual local context file exists.

Move the ensureGitignoreEntry call after fs.writeFile succeeds (just before the return). The try/catch wrapper already degrades failures, so ordering it post-write is straightforward.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review


const MAX_MEMORY_CONTENT_BYTES = 1024 * 1024;

const VALID_SCOPES: ReadonlySet<string> = new Set([

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] VALID_SCOPES is a runtime Set<string>, while WriteContextFileScope is a compile-time type union. They are maintained independently — adding a scope to one without the other causes silent drift (route rejects type-valid scopes, or resolver falls through to global for unknown scopes).

Derive the type from the set:

const VALID_SCOPES = ['workspace', 'global', 'local', 'auto'] as const;
type WriteContextFileScope = (typeof VALID_SCOPES)[number];

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

- Add symlink guard in ensureGitignoreEntry to prevent .gitignore
  symlink-target corruption (security fix)
- Use findProjectRoot() in workspaceMemory.ts local probe so writes
  and discovery agree on project root in monorepo subdirectory binds
- Add resolvedScope to WriteContextFileResult; emit resolved scope
  (never 'auto') in SSE memory_changed events for SDK cache invalidation
- Refuse mode='replace' when scope='auto' resolves to global, and
  log warning on auto→global fallback (data safety)
- Discriminate non-ENOENT errors in memoryDiscovery local file probe
  (EACCES/EPERM now logged as warning instead of silently swallowed)
- Extract getLocalContextFilePath() helper to eliminate 5 duplicate
  path-join call sites

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@launchswitch

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @wenshao! All remaining findings from the additional review round are now addressed in `1bce6c6`:

# Finding Fix
1 Symlink attack on .gitignore — malicious repo can ship .gitignore as symlink, corrupting symlink target on first local-scope write Added lstat/isSymbolicLink() guard in ensureGitignoreEntry; logs warning and skips if symlink detected
2 Monorepo path mismatch — POST route probes boundWorkspace while discovery uses findProjectRoot(resolvedCwd), causing writes to disappear in monorepo subdirectory binds workspaceMemory.ts now uses findProjectRoot() for the local file probe, matching the discovery path exactly
3 Bare catch {} in discovery — swallows EACCES/EPERM/EIO from fs.access, silently skipping unreadable local files Now discriminates ENOENT (silent skip) from all other errors (logged as warning)
4 SSE event emits unresolved scope: 'auto' — SDK clients maintaining per-scope caches cannot determine which cache to invalidate Added resolvedScope to WriteContextFileResult; SSE event now emits the resolved scope ('workspace' / 'local' / 'global'), never 'auto'
5 auto→global safetyauto with mode='replace' wipes all global memories; no warning on fallback Refuses replace when auto resolves to global (requires explicit scope='global'); logs warning on auto→global fallback
6 Code quality — 5 hand-rolled path.join(root, QWEN_DIR, LOCAL_CONTEXT_FILENAME) call sites; findProjectRoot not reusable Extracted getLocalContextFilePath() helper to const.ts; exported findProjectRoot from memoryDiscovery.ts for reuse

All 87 tests pass (37 writeContextFile + 22 memoryDiscovery + 28 ignorePatterns), including 18 new tests covering the symlink guard, resolvedScope assertions, auto→global replace refusal, and interleaved filename ordering. Build, typecheck, and lint are clean.

@launchswitch launchswitch requested a review from wenshao May 21, 2026 23:05

if (scope === 'local') {
return {
filePath: getLocalContextFilePath(projectRoot),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] resolveContextFilePath uses projectRoot directly for 'local' and 'auto' scopes, but the read paths in memoryDiscovery.ts:393 and collectWorkspaceMemoryStatus:356 both call findProjectRoot() to resolve the git root before probing for .qwen/QWEN.local.md. In a monorepo where the daemon is bound to a subdirectory:

  • POST writes to <subdir>/.qwen/QWEN.local.md
  • GET reads from <git-root>/.qwen/QWEN.local.md
  • Discovery loads from <git-root>/.qwen/QWEN.local.md

The GET side was fixed (the comment at workspaceMemory.ts:350-354 explicitly warns about this), but the write path was not updated to match. Written memories silently disappear from the prompt.

Suggested change
filePath: getLocalContextFilePath(projectRoot),
if (scope === 'local') {
const gitRoot = (await findProjectRoot(projectRoot)) ?? projectRoot;
return {
filePath: getLocalContextFilePath(gitRoot),
resolvedScope: 'local',
};
}

The same findProjectRoot resolution should be applied to the 'auto' branch before checking candidates.

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

@@ -206,6 +266,7 @@ async function runWrite(
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] fs.writeFile(filePath, ...) follows symlinks on the write target. If .qwen/QWEN.local.md is committed as a symlink pointing to ~/.ssh/authorized_keys, ~/.bashrc, or any other sensitive file, writeFile dereferences it and overwrites the target.

ensureGitignoreEntry correctly guards .gitignore against symlinks with lstat — but the actual write target has no equivalent guard. The defense is applied to the wrong file.

Suggested change
});
// Before writeFile, check the target is not a symlink:
try {
const targetLstat = await fs.lstat(filePath);
if (targetLstat.isSymbolicLink()) {
throw new Error(
`Refusing to write: ${filePath} is a symlink. Remove the symlink and retry.`,
);
}
} catch (err) {
if (!isEnoent(err)) throw err;
}
await fs.writeFile(filePath, next, { encoding: 'utf8', mode: 0o644 });

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

if (folderTrust && !options.explicitOnly) {
const localPath = getLocalContextFilePath(effectiveRoot);
try {
await fs.access(localPath, fsSync.constants.R_OK);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] fs.access(localPath, fsSync.constants.R_OK) follows symlinks. If .qwen/QWEN.local.md is a symlink to a sensitive file (e.g., ~/.aws/credentials, ~/.ssh/id_rsa), the access check passes, and the target's content is subsequently read by readGeminiMdFiles and injected into the system prompt sent to the LLM — a prompt injection / data exfiltration vector.

Suggested change
await fs.access(localPath, fsSync.constants.R_OK);
try {
const localLstat = await fs.lstat(localPath);
if (localLstat.isSymbolicLink()) {
logger.warn(`Skipping symlinked local context file: ${localPath}`);
} else {
await fs.access(localPath, fsSync.constants.R_OK);
const globalQwenDir = Storage.getGlobalQwenDir();
// ... rest of the insertion logic
}
}

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

*/
export interface DaemonMemoryChangedData {
scope: 'workspace' | 'global';
scope: 'workspace' | 'local' | 'global' | 'auto';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] DaemonMemoryChangedData.scope includes 'auto', and the JSDoc states "'auto' may appear when the daemon did not resolve the scope before emitting the event." However, workspaceMemory.ts:217 always emits result.resolvedScope (typed 'workspace' | 'local' | 'global') — 'auto' is never present at runtime. The type guard at line 1517 also accepts 'auto', validating a value that cannot occur.

SDK consumers writing exhaustive switches over scope must handle an 'auto' case that never fires. Additionally, DaemonContextFileScope in types.ts:356 was not updated to include 'local', creating an inconsistency: DaemonMemoryChangedData has 'local' but DaemonContextFileScope does not.

Suggested change
scope: 'workspace' | 'local' | 'global' | 'auto';
scope: 'workspace' | 'local' | 'global';

And update DaemonContextFileScope in types.ts to also include 'local'.

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

`scope='auto' with mode='replace' is not allowed when auto resolves ` +
`to the global directory. Use scope='global' to explicitly target ` +
`the global memory file, or use mode='append'.`,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The safety guard correctly blocks scope='auto' + mode='replace' + resolved global — but scope='auto' + mode='append' + resolved global is still allowed. The logger.warn at line 392 acknowledges that "project-specific content written via auto will leak into every future session for every project", yet the write proceeds.

A confused agent or HTTP client using scope='auto' in a fresh project (no QWEN.md) will silently append project-specific memories (potentially containing API keys, internal architecture notes, or customer data) to the global file.

Consider also blocking auto+append+global or requiring explicit confirmation, similar to the replace guard.

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

@launchswitch

Copy link
Copy Markdown
Contributor Author

@wenshao thanks for the follow-up review. Addressed the latest feedback in 0f8ce0829:

  • Local and auto writes now resolve local memory paths through the git root, matching GET/discovery behavior for monorepo subdirectory binds.
  • Local writes now reject symlinked .qwen/ directories and symlinked target files; append mode checks before reading and again before writing.
  • Discovery now uses lstat for .qwen/QWEN.local.md and skips symlinked local memory files instead of following them into arbitrary targets.
  • scope="auto" now refuses both append and replace when it would resolve to global; callers must use explicit scope="global" for global memory writes.
  • SDK event types/guards now only accept resolved scopes (workspace | local | global), and DaemonContextFileScope includes local.
  • Added regression coverage for the monorepo path resolution, symlink protections, auto global refusal, and SDK event guard behavior.

Verification run:

  • cd packages/core && npx vitest run src/memory/writeContextFile.test.ts
  • cd packages/core && npx vitest run src/utils/memoryDiscovery.test.ts
  • cd packages/sdk-typescript && npx vitest run test/unit/daemonEvents.test.ts
  • npm run build
  • npm run typecheck
  • git diff --check

All passed. npm run build still reports the unrelated existing warning in packages/vscode-ide-companion/src/utils/editorGroupUtils.ts.

@launchswitch launchswitch requested a review from wenshao May 23, 2026 13:59
for (const candidateName of candidates) {
const candidatePath = path.join(gitRoot, candidateName);
try {
const stat = await fs.stat(candidatePath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing test coverage for this branch: scope='auto' resolving to a committed QWEN.md/AGENTS.md at the git root when gitRoot !== projectRoot. The existing monorepo tests cover auto→local at git root and local scope at git root, but not the committed-file-at-gitRoot case.

Suggested test:

it('resolves auto to committed QWEN.md at git root when projectRoot is a subdirectory', async () => {
  const gitRoot = path.join(tmpRoot, 'repo');
  const subWorkspace = path.join(gitRoot, 'packages', 'cli');
  await fs.mkdir(subWorkspace, { recursive: true });
  await fs.mkdir(path.join(gitRoot, '.git'), { recursive: true });
  await fs.writeFile(path.join(gitRoot, '.git', 'HEAD'), 'ref: main\n');
  await fs.writeFile(path.join(gitRoot, DEFAULT_CONTEXT_FILENAME), '# root\n', 'utf8');

  const result = await writeWorkspaceContextFile({
    scope: 'auto',
    mode: 'append',
    content: '- monorepo auto entry',
    projectRoot: subWorkspace,
  });

  expect(result.filePath).toBe(path.join(gitRoot, DEFAULT_CONTEXT_FILENAME));
  expect(result.resolvedScope).toBe('workspace');
  expect(result.resolvedRoot).toBe(gitRoot);
});

— qwen3.7-max via Qwen Code /review

launchswitch and others added 2 commits May 23, 2026 12:36
Add test coverage for scope='auto' resolving to a committed QWEN.md at
the git root when projectRoot is a monorepo subdirectory. Expose
resolvedRoot in WriteContextFileResult so callers can distinguish which
root was used for path resolution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@launchswitch

launchswitch commented May 23, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the remaining test-coverage gap from the latest review in 4d7da07.

What changed:

  • Added monorepo regression coverage for scope=auto resolving to a committed AGENTS.md at the git root when projectRoot is a subdirectory.
  • Asserted filePath, resolvedScope === workspace, and resolvedRoot === gitRoot to match the QWEN.md git-root case already covered.

Verification:

  • cd packages/core && npx vitest run src/memory/writeContextFile.test.ts
  • Passed: 47/47 tests

@launchswitch launchswitch requested a review from wenshao May 23, 2026 18:10
if (stat.isFile()) {
files.push({
absolutePath: localPath,
scope: 'local',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] [typecheck] TS2322: Type '"local"' is not assignable to type 'ServeContextFileScope'. The object literal here uses absolutePath but ServeWorkspaceMemoryFile expects path, and is missing the required kind: 'memory_file' property. TypeScript cannot unify the literal 'local' with the union type when the enclosing object shape diverges from the interface.

Suggested change
scope: 'local',
files.push({
kind: 'memory_file' as const,
path: localPath,
scope: 'local' as const,
bytes: stat.size,
});

— qwen3.7-max via Qwen Code /review

const effectiveRoot = foundProjectRoot ?? resolvedWorkspace;
const localPath = getLocalContextFilePath(effectiveRoot);
try {
const stat = await fs.stat(localPath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] fs.stat follows symlinks, but the prompt-loading path (memoryDiscovery.ts:410) uses fs.lstat and skips symlinks, and the write path (writeContextFile.ts:333 assertNotSymlink) refuses them. A symlinked .qwen/QWEN.local.md would appear healthy here (reporting the target's stat.size) while being silently absent from the prompt and rejected on write — three different behaviors for the same file.

Suggested change
const stat = await fs.stat(localPath);
const stat = await fs.lstat(localPath);
if (stat.isSymbolicLink()) {
// Skip — consistent with memoryDiscovery.ts and writeContextFile.ts
} else if (stat.isFile()) {

— qwen3.7-max via Qwen Code /review

const gitPath = path.join(currentDir, '.git');
try {
const stats = await fs.lstat(gitPath);
if (stats.isDirectory()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] stats.isDirectory() rejects .git files. In git worktrees, .git is a regular file containing gitdir: /path/to/main/.git/worktrees/<name>. This PR makes findProjectRoot the single source of truth for local/auto scope path resolution (called from 3 sites: write path, GET probe, and prompt discovery). Every worktree user's memory writes would resolve to the wrong directory.

Suggested change
if (stats.isDirectory()) {
if (stats.isDirectory() || stats.isFile()) {
// For directories, verify HEAD readability (existing sandbox guard).
// For files (worktrees/submodules), the file's existence is sufficient.
if (stats.isDirectory()) {
try {
await fs.access(path.join(gitPath, 'HEAD'), fsSync.constants.R_OK);
} catch {
currentDir = path.dirname(currentDir);
continue;
}
}
return currentDir;
}

— qwen3.7-max via Qwen Code /review

// global/AGENTS.md) behind the local file.
let lastGlobalIdx = -1;
for (let i = 0; i < filePaths.length; i++) {
if (filePaths[i].startsWith(globalQwenDir)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] startsWith(globalQwenDir) is a raw string prefix check without a path-separator boundary. If globalQwenDir is /home/user/.qwen and a workspace file path begins with /home/user/.qwen-custom/ or /home/user/.qwen2/, this false-positives, causing the local context file to be spliced at the wrong position in the priority-ordered array.

Suggested change
if (filePaths[i].startsWith(globalQwenDir)) {
const globalPrefix = globalQwenDir.endsWith(path.sep) ? globalQwenDir : globalQwenDir + path.sep;
if (filePaths[i].startsWith(globalPrefix)) {

— qwen3.7-max via Qwen Code /review

@@ -183,7 +193,7 @@ export function mountWorkspaceMemoryRoutes(

try {
const result = await writeWorkspaceContextFile({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When scope='auto' resolves to global, writeContextFile.ts:182 throws a plain Error. The route's generic catch-all (line ~299) maps it to HTTP 500 with code: 'file_error', but this is semantically a client-side resolution failure ("create a project context file first"), not a server error. SDK consumers branching on HTTP status or code would misclassify this as a transient infrastructure failure.

Consider defining a typed error class (e.g. AutoScopeUnresolvableError) and catching it before the generic handler to return 400 with code: 'auto_scope_unresolvable'.

— qwen3.7-max via Qwen Code /review

type: 'memory_changed',
data: {
scope,
scope: result.resolvedScope,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The synchronous POST response (line ~202) omits resolvedScope and resolvedRoot, so callers using scope: 'auto' must parse the filePath string to infer whether the write went to workspace, local, or global. The memory_changed SSE event here already emits scope: result.resolvedScope — add the same fields to the response body for consistency.

— qwen3.7-max via Qwen Code /review

@@ -337,6 +347,36 @@ async function collectWorkspaceMemoryStatus(
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No test covers this new collectWorkspaceMemoryStatus local file probe (success or error path). Also, no route-level test verifies that scope: 'local' or scope: 'auto' are accepted by the POST route, or that the memory_changed event emits result.resolvedScope instead of the raw scope. Additionally, writeContextFile.ts:551 (WorkspaceMemoryFileTooLargeError / 16MB cap) has no test coverage.

— qwen3.7-max via Qwen Code /review

@LaZzyMan LaZzyMan added the type/feature-request New feature or enhancement request label May 26, 2026
@LaZzyMan

Copy link
Copy Markdown
Collaborator

Thanks @launchswitch for the careful work on this — the multiple rounds of review iteration show, and the defensive posture (per-file mutex with timeout, three-layer symlink defense around the read-modify-write cycle, .gitignore symlink protection, fenced-code-block-aware section detection, the 16 MB existing-file cap, ReDoS-safe hand-rolled whitespace trims) is genuinely solid work.

I'm going to close this in favor of #4394, which is the reviewer-authored alternative for the same .qwen/QWEN.local.md feature (also closes #4091) and is further along in the review cycle. The two PRs landed independently with overlapping scope; merging both would duplicate the discovery probe, the ignore-pattern entry, and the 'local' scope wiring in the SDK and bridge types.

A few technical findings from this PR that I'll carry into #4394:

  1. findProjectRoot should recognize .git files (worktrees, submodules), not just directories. The current implementation walks past the worktree marker and resolves to the outer repo, so .qwen/QWEN.local.md ends up shared across every nested worktree of the same parent repo — write and read are consistent so no data loss, but it defeats the per-checkout scoping users expect.
  2. The SDK DaemonWriteMemoryRequest.scope union should accept 'auto'. The server-side route accepts it but the type surface excludes it, so TypeScript SDK consumers can't use the headline feature without casts. Splitting into a request-side union (with 'auto') and a response/status-side union (where auto is always already resolved) mirrors the internal WriteContextFileResult.resolvedScope split.

Two smaller notes if you ever pick this up again or want to contribute to #4394:

  • ensureGitignoreEntry has an inline ENOENT check that duplicates the file's own isEnoent() helper — easy consistency miss to close.
  • The fenced-code-block detector in findNextTopLevelHeading is column-0 strict; CommonMark allows fences indented up to 3 spaces.

Closing as superseded by #4394. The work on the test harness and the symlink defense was genuinely useful — appreciate the contribution.

@LaZzyMan

Copy link
Copy Markdown
Collaborator

Superseded by #4394 — see review comment above for the technical findings being carried forward.

@LaZzyMan LaZzyMan closed this May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add project-level local context file (QWEN.local.md) Support for Project-Specific Memory Storage in QWEN.md

4 participants