feat(memory): project-scoped memory writes and .qwen/QWEN.local.md#4290
feat(memory): project-scoped memory writes and .qwen/QWEN.local.md#4290launchswitch wants to merge 10 commits into
Conversation
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>
| try { | ||
| const result = await writeWorkspaceContextFile({ | ||
| scope, | ||
| scope: scope as WriteContextFileScope, |
There was a problem hiding this comment.
[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); |
There was a problem hiding this comment.
[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.
| 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'; |
There was a problem hiding this comment.
[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.
| // non-global path and insert local before it so the final order | ||
| // is: global → local → committed. | ||
| const insertIdx = filePaths.findIndex( | ||
| (p) => !p.startsWith(globalQwenDir), |
There was a problem hiding this comment.
[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.
| (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); |
There was a problem hiding this comment.
[Suggestion] Two issues with ensureGitignoreEntry placement:
-
Error propagation: on EACCES, EROFS, or any non-ENOENT
.gitignoreerror, the exception kills the entire memory write — even though the memory file itself could be written successfully. A read-only.gitignoreprevents all local-scope memory writes. -
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 withscope: 'local'still modifies.gitignoredespite the no-op contract.
| 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 () => { |
There was a problem hiding this comment.
[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.
| 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', |
There was a problem hiding this comment.
[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
|
Thanks for the thorough review, @wenshao! All seven items are now addressed across two commits (
All 76 tests pass, |
wenshao
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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.
| 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'); |
There was a problem hiding this comment.
[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.
| 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 { |
There was a problem hiding this comment.
[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.
| } 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); |
There was a problem hiding this comment.
[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.
| 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); |
There was a problem hiding this comment.
[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 === |
There was a problem hiding this comment.
[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); |
There was a problem hiding this comment.
[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([ |
There was a problem hiding this comment.
[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>
|
Thanks for the thorough review, @wenshao! All remaining findings from the additional review round are now addressed in `1bce6c6`:
All 87 tests pass (37 writeContextFile + 22 memoryDiscovery + 28 ignorePatterns), including 18 new tests covering the symlink guard, |
|
|
||
| if (scope === 'local') { | ||
| return { | ||
| filePath: getLocalContextFilePath(projectRoot), |
There was a problem hiding this comment.
[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.
| 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( | |||
| }); | |||
There was a problem hiding this comment.
[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.
| }); | |
| // 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); |
There was a problem hiding this comment.
[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.
| 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'; |
There was a problem hiding this comment.
[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.
| 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'.`, | ||
| ); |
There was a problem hiding this comment.
[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
|
@wenshao thanks for the follow-up review. Addressed the latest feedback in
Verification run:
All passed. |
| for (const candidateName of candidates) { | ||
| const candidatePath = path.join(gitRoot, candidateName); | ||
| try { | ||
| const stat = await fs.stat(candidatePath); |
There was a problem hiding this comment.
[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
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>
|
Addressed the remaining test-coverage gap from the latest review in 4d7da07. What changed:
Verification:
|
| if (stat.isFile()) { | ||
| files.push({ | ||
| absolutePath: localPath, | ||
| scope: 'local', |
There was a problem hiding this comment.
[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.
| 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); |
There was a problem hiding this comment.
[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.
| 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()) { |
There was a problem hiding this comment.
[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.
| 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)) { |
There was a problem hiding this comment.
[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.
| 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({ | |||
There was a problem hiding this comment.
[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, |
There was a problem hiding this comment.
[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( | |||
| ); | |||
There was a problem hiding this comment.
[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
|
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, I'm going to close this in favor of #4394, which is the reviewer-authored alternative for the same A few technical findings from this PR that I'll carry into #4394:
Two smaller notes if you ever pick this up again or want to contribute to #4394:
Closing as superseded by #4394. The work on the test harness and the symlink defense was genuinely useful — appreciate the contribution. |
|
Superseded by #4394 — see review comment above for the technical findings being carried forward. |
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 toWriteContextFileScopesosave_memorycan write to the project-level context file (QWEN.mdorAGENTS.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.mdsupport 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'sCLAUDE.local.md.scope='auto'+ projectQWEN.mdexistsQWEN.mdscope='auto'+ projectAGENTS.mdexists (noQWEN.md)AGENTS.mdscope='auto'+.qwen/QWEN.local.mdexists (no committed file).qwen/QWEN.local.mdscope='auto'+ none exist~/.qwen/QWEN.mdscope='local'.qwen/QWEN.local.md(creates dir + gitignore)Precedence order (later in prompt = higher priority): global → local → committed
Changes
writeContextFile.ts: AsyncresolveContextFilePathwith'auto'and'local'scopes;ensureGitignoreEntry()helper for local file writesmemoryDiscovery.ts: Singlefs.access()probe for local file at project root, spliced between global and committed pathsworkspaceMemory.ts: Accepts'auto'and'local'in POST validation; GET route surfaces local file withscope: 'local'status.ts:ServeContextFileScopeextended to include'local'ignorePatterns.ts: Excludes.qwen/QWEN.local.mdfrom file searchesconst.ts/memory-config.ts:LOCAL_CONTEXT_FILENAMEconstantTest plan
memoryDiscovery.test.ts(21),writeContextFile.test.ts(26),ignorePatterns.test.ts(28)coreandclipackagesscope='local'creates.qwen/dir, manages.gitignore, no duplicate entriesCloses #359
Closes #4091
🤖 Generated with Claude Code