feat(memory): managed auto-memory and auto-dream system (#3087)

* docs: add auto-memory implementation log

* feat(core): add managed auto-memory storage scaffold

* feat(core): load managed auto-memory index

* feat(core): add managed auto-memory recall

* feat(core): add managed auto-memory extraction

* feat(cli): add managed auto-memory dream commands

* feat(core): add auxiliary side-query foundation

* feat(memory): add model-driven recall selection

* feat(memory): add model-driven extraction planner

* feat(core): add background task runtime foundation

* feat(memory): schedule auto dream in background

* feat(core): add background agent runner foundation

* feat(memory): add extraction agent planner

* feat(core): add dream agent planner

* feat(core): rebuild managed memory index

* feat(memory): add governance status commands

* feat(memory): add managed forget flow

* feat(core): harden background agent planning

* feat(memory): complete managed parity closure

* test(memory): add managed lifecycle integration coverage

* feat: same to cc

* feat(memory-ui): add memory saved notification and memory count badge

Feature 3 - Memory Saved Notification:
- Add HistoryItemMemorySaved type to types.ts
- Create MemorySavedMessage component for rendering '● Saved/Updated N memories'
- In useGeminiStream: detect in-turn memory writes via mapToDisplay's
  memoryWriteCount field and emit 'memory_saved' history item after turn
- In client.ts: capture background dream/extract promises and expose
  via consumePendingMemoryTaskPromises(); useGeminiStream listens
  post-turn and emits 'Updated N memories' notification for background tasks

Feature 4 - Memory Count Badge:
- Add isMemoryOp field to IndividualToolCallDisplay
- Add memoryWriteCount/memoryReadCount to HistoryItemToolGroup
- Add detectMemoryOp() in useReactToolScheduler using isAutoMemPath
- ToolGroupMessage renders '● Recalled N memories, Wrote N memories' badge
  at the top of tool groups that touch memory files

Fix: process.env bracket-access in paths.ts (noPropertyAccessFromIndexSignature)
Fix: MemoryDialog.test.tsx mock useSettings to satisfy SettingsProvider requirement

* fix(memory-ui): auto-approve memory writes, collapse memory tool groups, fix MEMORY.md path

Problem 1 - Auto-approve memory file operations:
- write-file.ts: getDefaultPermission() checks isAutoMemPath; returns 'allow'
  for managed auto-memory files, 'ask' for all other files
- edit.ts: same pattern

Problem 2 - Feature 4 UX: collapse memory-only tool groups:
- ToolGroupMessage: detect when all tool calls have isMemoryOp set (pure memory
  group) and all are complete; render compact '● Recalled/Wrote N memories
  (ctrl+o to expand)' instead of individual tool call rows
- ctrl+o toggles expand/collapse when isFocused and group is memory-only
- Mixed groups (memory + other tools) keep badge-at-top behaviour
- Expanded state shows individual tool calls with '● Memory operations
  (ctrl+o to collapse)' header

Problem 3 - MEMORY.md path mismatch:
- prompt.ts: Step 2 now references full absolute path ${memoryDir}/MEMORY.md
  so the model writes to the correct location inside the memory directory,
  not to the parent project directory

Fix tests:
- write-file.test.ts: add getProjectRoot to mockConfigInternal
- prompt.test.ts: update assertion to match full-path section header

* fix(memory-ui): fix duplicate notification, broken ctrl+o, and Edit tool detection

- Remove duplicate 'Saved N memories' notification: the tool group badge already
  shows 'Wrote N memories'; the separate HistoryItemMemorySaved addItem after
  onComplete was double-counting. Keep only the background-task path
  (consumePendingMemoryTaskPromises).

- Remove ctrl+o expand: Ink's Static area freezes items on first render and
  cannot respond to user input. useInput/useState(isExpanded) in a Static item
  is a no-op. Removed the dead code; memory-only groups now always render as
  the compact summary (no fake interactive hint).

- Fix Edit tool detection: detectMemoryOp was checking for 'edit_file' but the
  real tool name constant is 'edit'. Also removed non-existent 'create_file'
  (write_file covers all writes). Now editing MEMORY.md is correctly identified
  as a memory write op, collapses to 'Wrote N memories', and is auto-approved.

* fix(dream): run /dream as a visible submit_prompt turn, not a silent background agent

The previous implementation ran an AgentHeadless background agent that could
take 5+ minutes with zero UI feedback — user saw a blank screen for the entire
duration and then at most one line of text.

Fix: /dream now returns submit_prompt with the consolidation task prompt so it
runs as a regular AI conversation turn. Tool calls (read_file, write_file, edit,
grep_search, list_directory, glob) are immediately visible as collapsed tool
groups as the model works through the memory files — identical UX to Claude Code.

Also export buildConsolidationTaskPrompt from dreamAgentPlanner so dreamCommand
can reuse the same detailed consolidation prompt that was already written.

* fix(memory): auto-allow ls/glob/grep on memory base directory

Add getMemoryBaseDir() to getDefaultPermission() allow list in ls.ts,
glob.ts, and grep.ts — mirrors the existing pattern in read-file.ts.

Without this, ListFiles/Glob/Grep on ~/.qwen/* would trigger an
approval dialog, blocking /dream at its very first step.

* fix(background): prevent permission prompt hangs in background agents

Match Claude Code's headless-agent intent: background memory agents must never
block on interactive permission prompts.

Wrap background runtime config so getApprovalMode() returns YOLO, ensuring any
ask decision is auto-approved instead of hanging forever. Add regression test
covering the wrapped approval mode.

* fix(memory): run auto extract through forked agent

Make managed auto-memory extraction follow the Claude Code architecture:
background extraction now uses a forked agent to read/write memory files
directly, instead of planning patches and applying them with a separate
filesystem pipeline.

Keep the old patch/model path only as fallback if the forked agent fails.
Add regression tests covering the new execution path and tool whitelist.

* refactor(memory): remove legacy extract fallback pipeline

Delete the old patch/model/heuristic extraction path entirely.
Managed auto-memory extract now runs only through the forked-agent
execution flow, with no planner/apply fallback stages remaining.

Also remove obsolete exports/tests and update scheduler/integration
coverage to use the forked-agent-only architecture.

* refactor(memory): move auxiliary files out of memory/ directory

meta.json, extract-cursor.json, and consolidation.lock are internal
bookkeeping files, not user-visible memories. Move them one level up
to the project state dir (parent of memory/) so that the memory/
directory contains only MEMORY.md and topic files, matching the
clean layout of the upstream reference implementation.

Add getAutoMemoryProjectStateDir() helper in paths.ts and update the
three path accessors + store.test.ts path assertions accordingly.

* fix(memory): record lastDreamAt after manual /dream run

The /dream command submits a prompt to the main agent (submit_prompt),
which writes memory files directly. Because it bypasses dreamScheduler,
meta.json was never updated and /memory always showed 'never'.

Fix by:
- Exporting writeDreamManualRunToMetadata() from dream.ts
- Adding optional onComplete callback to SubmitPromptActionReturn and
  SubmitPromptResult (types.ts / commands/types.ts)
- Propagating onComplete through slashCommandProcessor.ts
- Firing onComplete after turn completion in useGeminiStream.ts
- Providing the callback in dreamCommand.ts to write lastDreamAt

* fix(memory): remove scope params from /remember in managed auto-memory mode

--global/--project are legacy save_memory tool concepts. In managed
auto-memory mode the forked agent decides the appropriate type
(user/feedback/project/reference) based on the content of the fact.

Also improve the prompt wording to explicitly ask the agent to choose
the correct type, reducing the tendency to default to 'project'.

* feat(ui): show '✦ dreaming' indicator in footer during background dream

Subscribe to getManagedAutoMemoryDreamTaskRegistry() in Footer via a
useDreamRunning() hook. While any dream task for the current project is
pending or running, display '✦ dreaming' in the right section of the
footer bar, between Debug Mode and context usage.

* refactor(memory): align dream/extract infrastructure with Claude Code patterns

Five improvements based on Claude Code parity audit:

1. Memoize getAutoMemoryRoot (paths.ts)
   - Add _autoMemoryRootCache Map, keyed by projectRoot
   - findCanonicalGitRoot() walks the filesystem per call; memoize avoids
     repeated git-tree traversal on hot-path schedulers/scanners
   - Expose clearAutoMemoryRootCache() for test teardown

2. Lock file stores PID + isProcessRunning reclaim (dreamScheduler.ts)
   - acquireDreamLock() writes process.pid to the lock file body
   - lockExists() reads PID and calls process.kill(pid, 0); dead/missing
     PID reclaims the lock immediately instead of waiting 2h
   - Stale threshold reduced to 1h (PID-reuse guard, same as CC)

3. Session scan throttle (dreamScheduler.ts)
   - Add SESSION_SCAN_INTERVAL_MS = 10min (same as CC)
   - Add lastSessionScanAt Map<projectRoot, number> to ManagedAutoMemoryDreamRuntime
   - When time-gate passes but session-gate doesn't, throttle prevents
     re-scanning the filesystem on every user turn

4. mtime-based session counting (dreamScheduler.ts)
   - Replace fragile recentSessionIdsSinceDream Set in meta.json with
     filesystem mtime scan (listSessionsTouchedSince)
   - Mirrors Claude Code's listSessionsTouchedSince: reads session JSONL
     files from Storage.getProjectDir()/chats/, filters by mtime > lastDreamAt
   - Immune to meta.json corruption/loss; no per-turn metadata write
   - ManagedAutoMemoryDreamRuntime accepts injectable SessionScannerFn
     for clean unit testing without real session files

5. Extraction mutual exclusion extended to write_file/edit (extractScheduler.ts)
   - historySliceUsesMemoryTool() now checks write_file/edit/replace/create_file
     tool calls whose file_path is within isAutoMemPath()
   - Previously only detected save_memory; missed direct file writes by
     the main agent, causing redundant background extraction

* docs(memory): add user-facing memory docs, i18n for all locales, simplify /forget

- Add docs/users/features/memory.md: comprehensive user-facing guide covering
  QWEN.md instructions, auto-memory behaviour, all memory commands, and
  troubleshooting; replaces the placeholder auto-memory.md
- Update docs/users/features/_meta.ts: rename entry auto-memory → memory
- Update docs/users/features/commands.md: add /init, /remember, /forget,
  /dream rows; fix /memory description; remove /init duplicate
- Update docs/users/configuration/settings.md: add memory.* settings section
  (enableManagedAutoMemory, enableManagedAutoDream) between tools and permissions
- Remove /forget --apply flag: preview-then-apply flow replaced with direct
  deletion; update forgetCommand.ts, en.js, zh.js accordingly
- Add all auto-memory i18n keys to de, ja, pt, ru locales (18 keys each):
  Open auto-memory folder, Auto-memory/Auto-dream status lines, never/on/off,
  ✦ dreaming, /forget and /remember usage strings, all managed-memory messages
- Remove dead save_memory branch from extractScheduler.partWritesToMemory()
- Add ✦ dreaming indicator to Footer.tsx with i18n; fix Footer.test.tsx mocks
- Refactor MemoryDialog.tsx auto-dream status line to use i18n
- Remove save_memory tool (memoryTool.ts/test); clean up webui references
- Add extractionPlanner.ts, const.ts and associated tests
- Delete stale docs/users/configuration/memory.md and
  docs/developers/tools/memory.md (content superseded)

* refactor(memory): remove all Claude Code references from comments and test names

* test(memory): remove empty placeholder test files that cause vitest to fail

* fix eslint

* fix test in windows

* fix test

* fix(memory): address critical review findings from PR #3087

- fix(read-file): narrow auto-allow from getMemoryBaseDir() (~/.qwen) to
  isAutoMemPath(projectRoot) to prevent exposing settings.json / OAuth
  credentials without user approval (wenshao review)

- fix(forget): per-entry deletion instead of whole-file unlink
  - assign stable per-entry IDs (relativePath:index for multi-entry files)
    so the model can target individual entries without removing siblings
  - rewrite file keeping unmatched entries; only unlink when file becomes
    empty (wenshao review)

- fix(entries): round-trip correctness for multi-entry new-format bodies
  - parseAutoMemoryEntries: plain-text line closes current entry and opens
    a new one (was silently ignored when current was already set)
  - renderAutoMemoryBody: emit blank line between adjacent entries so the
    parser can detect entry boundaries on re-read (wenshao review)

- fix(entries): resolve two CodeQL polynomial-regex alerts
  - indentedMatch: \s{2,}(?:[-*]\s+)? → [\t ]{2,}(?:[-*][\t ]+)?
  - topLevelMatch: :\s*(.+)$ → :[ \t]*(\S.*)$
  (github-advanced-security review)

- fix(scan.test): use forward-slash literal for relativePath expectation
  since listMarkdownFiles() normalises all separators to '/' on all
  platforms including Windows

* fix(memory): replace isAutoMemPath startsWith with path.relative()

Using path.relative() instead of string startsWith() is more robust
across platforms — it correctly handles Windows path-separator
differences and avoids potential edge cases where a path prefix match
could succeed on non-separator boundaries.

Addresses github-actions review item 3 (PR #3087).

* feat(telemetry): add auto-memory telemetry instrumentation

Add OpenTelemetry logs + metrics for the five auto-memory lifecycle
events: extract, dream, recall, forget, and remember.

Telemetry layer (packages/core/src/telemetry/):
- constants.ts: 5 new event-name constants
  (qwen-code.memory.{extract,dream,recall,forget,remember})
- types.ts: 5 new event classes with typed constructor params
  (MemoryExtractEvent, MemoryDreamEvent, MemoryRecallEvent,
   MemoryForgetEvent, MemoryRememberEvent)
- metrics.ts: 8 new OTel instruments (5 Counters + 3 Histograms)
  with recordMemoryXxx() helpers; registered inside initializeMetrics()
- loggers.ts: logMemoryExtract/Dream/Recall/Forget/Remember() — each
  emits a structured log record and calls its recordXxx() counterpart
- index.ts: re-exports all new symbols

Instrumentation call-sites:
- extractScheduler.ts ManagedAutoMemoryExtractRuntime.runTask():
  emits extract event with trigger=auto, completed/failed status,
  patches_count, touched_topics, and wall-clock duration
- dream.ts runManagedAutoMemoryDream():
  emits dream event with trigger=auto, updated/noop status,
  deduped_entries, touched_topics, and duration; covers both
  agent-planner and mechanical fallback paths
- recall.ts resolveRelevantAutoMemoryPromptForQuery():
  emits recall event with strategy, docs_scanned/selected, and
  duration; covers model, heuristic, and none paths
- forget.ts forgetManagedAutoMemoryEntries():
  emits forget event with removed_entries_count, touched_topics,
  and selection_strategy (model/heuristic/none)
- rememberCommand.ts action():
  emits remember event with topic=managed|legacy at command
  invocation time (before agent decides the actual memory type)

* refactor(telemetry): remove memory forget/remember telemetry events

Remove EVENT_MEMORY_FORGET and EVENT_MEMORY_REMEMBER along with all
associated infrastructure that is no longer needed:

- constants.ts: remove EVENT_MEMORY_FORGET, EVENT_MEMORY_REMEMBER
- types.ts: remove MemoryForgetEvent, MemoryRememberEvent classes
- metrics.ts: remove MEMORY_FORGET_COUNT, MEMORY_REMEMBER_COUNT constants,
  memoryForgetCounter, memoryRememberCounter module vars,
  their initialization in initializeMetrics(), and
  recordMemoryForgetMetrics(), recordMemoryRememberMetrics() functions
- loggers.ts: remove logMemoryForget(), logMemoryRemember() functions
  and their imports
- index.ts: remove all re-exports for the above symbols
- memory/forget.ts: remove logMemoryForget call-site and import
- cli/rememberCommand.ts: remove logMemoryRemember call-sites and import

* change default value

* fix forked agent

* refactor(background): unify fork primitives into runForkedAgent + cleanup

- Merge runForkedQuery into runForkedAgent via TypeScript overloads:
  with cacheSafeParams → GeminiChat single-turn path (ForkedQueryResult)
  without cacheSafeParams → AgentHeadless multi-turn path (ForkedAgentResult)
- Delete forkedQuery.ts; move its test to background/forkedAgent.cache.test.ts
- Remove forkedQuery export from followup/index.ts
- Migrate all callers (suggestionGenerator, speculation, btwCommand, client)
  to import from background/forkedAgent
- Add getFastModel() / setFastModel() to Config; expose in CLI config init
  and ModelDialog / modelCommand
- Remove resolveFastModel() from AppContainer — now delegated to config.getFastModel()
- Strip Claude Code references from code comments

* fix(memory): address wenshao's critical review findings

- dream.ts: writeDreamManualRunToMetadata now persists lastDreamSessionId
  and resets recentSessionIdsSinceDream, preventing auto-dream from firing
  again in the same session after a manual /dream
- config.ts: gate managed auto-memory injection on getManagedAutoMemoryEnabled();
  when disabled, previously saved memories are no longer injected into new sessions
- rememberCommand.ts: remove legacy save_memory branch (tool was removed);
  fall back to submit_prompt directing agent to write to QWEN.md instead
- BuiltinCommandLoader.ts: only register /dream and /forget when managed
  auto-memory is enabled, matching the feature's runtime availability
- forget.ts: return early in forgetManagedAutoMemoryMatches when matches is
  empty, avoiding unnecessary directory scaffolding as a side effect

* fix test

* fix ci test

* feat(memory): align extract/dream agents to Claude Code patterns

- fix(client): move saveCacheSafeParams before early-return paths so
  extract agents always have cache params available (fixes extract never
  triggering in skipNextSpeakerCheck mode)

- feat(extract): add read-only shell tool + memory-scoped write
  permissions; create inline createMemoryScopedAgentConfig() with
  PermissionManager wrapper (isToolEnabled + evaluate) that allows only
  read-only shell commands and write/edit within the auto-memory dir

- feat(extract): align prompt to Claude Code patterns — manifest block
  listing existing files, parallel read-then-write strategy, two-step
  save (memory file then index)

- feat(dream): remove mechanical fallback; runManagedAutoMemoryDream is
  now agent-only and throws without config

- feat(dream): align prompt to Claude Code 4-phase structure
  (Orient/Gather/Consolidate/Prune+Index); add narrow transcript grep,
  relative→absolute date conversion, stale index pruning, index size cap

- fix(permissions): add isToolEnabled() to MemoryScopedPermissionManager
  to prevent TypeError crash in CoreToolScheduler._schedule

- test: update dreamScheduler tests to mock dream.js; replace removed
  mechanical-dedup test with scheduler infrastructure verification

* move doc to design

* refactor(memory): unify extract+dream background task management into MemoryBackgroundTaskHub

- Add memoryTaskHub.ts: single BackgroundTaskRegistry + BackgroundTaskDrainer shared
  by all memory background tasks; exposes listExtractTasks() / listDreamTasks()
  typed query helpers and a unified drain() method
- extractScheduler: ManagedAutoMemoryExtractRuntime accepts hub via constructor
  (defaults to defaultMemoryTaskHub); test factory gets isolated fresh hub
- dreamScheduler: same pattern — sessionScanner + hub injection; BackgroundTask-
  Scheduler initialized from injected hub; test factory gets isolated hub
- status.ts: replace two separate getRegistry() calls with defaultMemoryTaskHub
  typed query methods
- Footer.tsx (useDreamRunning): subscribe to shared registry, filter by
  DREAM_TASK_TYPE so extract tasks do not trigger the dream spinner
- index.ts: re-export memoryTaskHub.ts so defaultMemoryTaskHub/DREAM_TASK_TYPE/
  EXTRACT_TASK_TYPE are available as top-level package exports

* refactor(background): introduce general-purpose BackgroundTaskHub

Replace memory-specific MemoryBackgroundTaskHub with a domain-agnostic
BackgroundTaskHub in the background/ layer. Any future background task
runtime (3rd, 4th, …) plugs in by accepting a hub via constructor
injection — no new infrastructure required.

Changes:
- Add background/taskHub.ts: BackgroundTaskHub (registry + drainer +
  createScheduler() + listByType(taskType, projectRoot?)) and the
  globalBackgroundTaskHub singleton. Zero knowledge of any task type.
- Delete memory/memoryTaskHub.ts: its narrow listExtractTasks /
  listDreamTasks helpers are replaced by the generic listByType() call.
- Move EXTRACT_TASK_TYPE to extractScheduler.ts (owned by the runtime
  that defines it); replace 3 hardcoded string literals with the const.
- Move DREAM_TASK_TYPE to dreamScheduler.ts; use hub.createScheduler()
  instead of manually wiring new BackgroundTaskScheduler(reg, drain).
- status.ts: globalBackgroundTaskHub.listByType(EXTRACT_TASK_TYPE, ...)
- Footer.tsx: globalBackgroundTaskHub.registry (shared, filtered by type)
- index.ts: export background/taskHub.js; drop memory/memoryTaskHub.js

* test(background): add BackgroundTaskHub unit tests and hub isolation checks

- background/taskHub.test.ts (11 tests):
  - createScheduler(): tasks registered via scheduler appear in hub registry;
    multiple calls return distinct scheduler instances
  - listByType(): filters by taskType, filters by projectRoot, returns []
    for unknown types, two types co-exist in registry but stay separated
  - drain(): resolves false on timeout, resolves true when tasks complete,
    resolves true immediately when no tasks in flight
  - isolation: tasks in hubA do not appear in hubB
  - globalBackgroundTaskHub: is a BackgroundTaskHub instance with registry/drainer

- extractScheduler.test.ts (+1 test):
  - factory-created runtimes have isolated registries; tasks in runtimeA
    are invisible to runtimeB; all tasks carry EXTRACT_TASK_TYPE

- dreamScheduler.test.ts (+1 test):
  - factory-created runtimes have isolated registries; tasks in runtimeA
    are invisible to runtimeB; all tasks carry DREAM_TASK_TYPE

* refactor(memory): consolidate all memory state into MemoryManager

Replace BackgroundTaskRegistry/Drainer/Scheduler/Hub helper classes and
module-level globals with a single MemoryManager class owned by Config.

## Changes

### New
- packages/core/src/memory/manager.ts — MemoryManager with:
  - scheduleExtract / scheduleDream (inline queuing + deduplication logic)
  - recall / forget / selectForgetCandidates / forgetMatches
  - getStatus / drain / appendToUserMemory
  - subscribe(listener) compatible with useSyncExternalStore
  - storeWith() atomic record registration (no double-notify)
  - Distinct skippedReason 'scan_throttled' vs 'min_sessions' for dream
- packages/core/src/utils/forkedAgent.ts — pure cache util (moved from background/)
- packages/core/src/utils/sideQuery.ts — pure util (moved from auxiliary/)

### Deleted
- background/taskRegistry, taskDrainer, taskScheduler, taskHub and all tests
- background/forkedAgent (moved to utils/)
- auxiliary/sideQuery (moved to utils/)
- memory/extractScheduler, dreamScheduler, state and all tests

### Modified
- config/config.ts — Config owns MemoryManager instance; getMemoryManager()
- core/client.ts — all memory ops via config.getMemoryManager()
- core/client.test.ts — mock MemoryManager instead of individual modules
- memory/status.ts — accepts MemoryManager param, drops globalBackgroundTaskHub
- index.ts — memory exports reduced from 14 modules to 5 (manager/types/paths/store/const)
- cli/commands/dreamCommand.ts — via config.getMemoryManager()
- cli/commands/forgetCommand.ts — via config.getMemoryManager()
- cli/components/Footer.tsx — useSyncExternalStore replacing setInterval polling
- cli/components/Footer.test.tsx — add getMemoryManager mock
This commit is contained in:
顾盼 2026-04-16 20:05:45 +08:00 committed by GitHub
parent 07475026f6
commit 9e2f63a1ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
137 changed files with 9809 additions and 2737 deletions

View file

@ -23,26 +23,35 @@ vi.mock('../../i18n/index.js', () => ({
},
}));
// Must use vi.hoisted so the mock factory can reference it before module eval.
const mockRunForkedAgent = vi.hoisted(() => vi.fn());
const mockGetCacheSafeParams = vi.hoisted(() =>
vi.fn().mockReturnValue({
generationConfig: {},
history: [],
model: 'test-model',
version: 1,
}),
);
vi.mock('@qwen-code/qwen-code-core', () => ({
runForkedAgent: mockRunForkedAgent,
getCacheSafeParams: mockGetCacheSafeParams,
}));
describe('btwCommand', () => {
let mockContext: CommandContext;
let mockGenerateContent: ReturnType<typeof vi.fn>;
let mockGetHistory: ReturnType<typeof vi.fn>;
const createConfig = (overrides: Record<string, unknown> = {}) => ({
getGeminiClient: () => ({
getHistory: mockGetHistory,
generateContent: mockGenerateContent,
}),
getGeminiClient: () => ({}),
getModel: () => 'test-model',
getSessionId: () => 'test-session-id',
getApprovalMode: () => 'default',
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockGenerateContent = vi.fn();
mockGetHistory = vi.fn().mockReturnValue([]);
mockContext = createMockCommandContext({
services: {
config: createConfig(),
@ -90,37 +99,14 @@ describe('btwCommand', () => {
});
});
it('should return error when model is not configured', async () => {
const noModelContext = createMockCommandContext({
services: {
config: createConfig({
getModel: () => '',
}),
},
});
const result = await btwCommand.action!(noModelContext, 'test question');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No model configured.',
});
});
describe('interactive mode', () => {
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0));
it('should set btwItem and update it on success', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'The answer is 42.' }],
},
},
],
mockRunForkedAgent.mockResolvedValue({
text: 'The answer is 42.',
usage: { inputTokens: 10, outputTokens: 5, cacheHitTokens: 3 },
});
await btwCommand.action!(mockContext, 'what is the meaning of life?');
@ -154,89 +140,25 @@ describe('btwCommand', () => {
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
it('should pass conversation history to generateContent', async () => {
const history = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi!' }] },
];
mockGetHistory.mockReturnValue(history);
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
it('should invoke runForkedAgent with cacheSafeParams and userMessage', async () => {
mockRunForkedAgent.mockResolvedValue({
text: 'answer',
usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 },
});
await btwCommand.action!(mockContext, 'my question');
await flushPromises();
expect(mockGenerateContent).toHaveBeenCalledWith(
[
...history,
{
role: 'user',
parts: [
{
text: expect.stringContaining('my question'),
},
],
},
],
{},
expect.any(AbortSignal),
'test-model',
expect.stringMatching(/^test-session-id########btw-/),
expect(mockRunForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({
cacheSafeParams: expect.objectContaining({ model: 'test-model' }),
userMessage: expect.stringContaining('my question'),
}),
);
});
it('should trim history to last 20 messages for long conversations', async () => {
// Build 24 history entries — exceeds the 20-message limit
const longHistory = Array.from({ length: 12 }, (_, i) => [
{ role: 'user', parts: [{ text: `Q${i}` }] },
{ role: 'model', parts: [{ text: `A${i}` }] },
]).flat();
mockGetHistory.mockReturnValue(longHistory);
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
});
await btwCommand.action!(mockContext, 'test');
await flushPromises();
const calledContents = mockGenerateContent.mock.calls[0][0];
// 20 history entries + 1 btw question = 21
expect(calledContents).toHaveLength(21);
// First entry should be user (Q2, since slice(-20) on 24 starts at index 4)
expect(calledContents[0].role).toBe('user');
expect(calledContents[0].parts[0].text).toBe('Q2');
});
it('should trim history and skip leading model entry to preserve alternation', async () => {
// Build 21 entries: 10 full turns + 1 trailing user message.
// slice(-20) yields [M0, U1, M1, ..., U9, M9, U10] — starts with model.
// trimHistory should drop that leading model entry.
const oddHistory = [
...Array.from({ length: 11 }, (_, i) => [
{ role: 'user', parts: [{ text: `Q${i}` }] },
{ role: 'model', parts: [{ text: `A${i}` }] },
]).flat(),
].slice(0, 21); // [U0, M0, U1, M1, ..., U9, M9, U10]
expect(oddHistory).toHaveLength(21);
mockGetHistory.mockReturnValue(oddHistory);
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
});
await btwCommand.action!(mockContext, 'test');
await flushPromises();
const calledContents = mockGenerateContent.mock.calls[0][0];
// slice(-20) = 20 entries starting with M0 (model) → slice(1) = 19, + 1 btw = 20
expect(calledContents).toHaveLength(20);
expect(calledContents[0].role).toBe('user');
expect(calledContents[0].parts[0].text).toBe('Q1');
});
it('should add error item on failure and clear btwItem', async () => {
mockGenerateContent.mockRejectedValue(new Error('API error'));
mockRunForkedAgent.mockRejectedValue(new Error('API error'));
await btwCommand.action!(mockContext, 'test question');
await flushPromises();
@ -255,7 +177,7 @@ describe('btwCommand', () => {
});
it('should handle non-Error exceptions', async () => {
mockGenerateContent.mockRejectedValue('string error');
mockRunForkedAgent.mockRejectedValue('string error');
await btwCommand.action!(mockContext, 'test question');
await flushPromises();
@ -270,6 +192,11 @@ describe('btwCommand', () => {
});
it('should not block when another pendingItem exists', async () => {
mockRunForkedAgent.mockResolvedValue({
text: 'answer',
usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 },
});
const busyContext = createMockCommandContext({
services: {
config: createConfig(),
@ -279,26 +206,21 @@ describe('btwCommand', () => {
},
});
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
});
// btw should NOT be blocked by pendingItem anymore
// btw should NOT be blocked by pendingItem
const result = await btwCommand.action!(busyContext, 'test question');
expect(result).toBeUndefined();
expect(busyContext.ui.setBtwItem).toHaveBeenCalled();
});
it('should not update btwItem when cancelled via btwAbortControllerRef', async () => {
mockGenerateContent.mockImplementation(
mockRunForkedAgent.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
candidates: [
{ content: { parts: [{ text: 'late answer' }] } },
],
text: 'late answer',
usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 },
}),
50,
),
@ -307,7 +229,6 @@ describe('btwCommand', () => {
await btwCommand.action!(mockContext, 'test question');
// The btw command should have registered its AbortController
expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf(
AbortController,
);
@ -323,25 +244,24 @@ describe('btwCommand', () => {
});
it('should clear btwAbortControllerRef after successful completion', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
mockRunForkedAgent.mockResolvedValue({
text: 'answer',
usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 },
});
await btwCommand.action!(mockContext, 'test question');
// Ref is set during the call
expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf(
AbortController,
);
await flushPromises();
// After completion, ref should be cleaned up
expect(mockContext.ui.btwAbortControllerRef.current).toBeNull();
});
it('should clear btwAbortControllerRef after error', async () => {
mockGenerateContent.mockRejectedValue(new Error('API error'));
mockRunForkedAgent.mockRejectedValue(new Error('API error'));
await btwCommand.action!(mockContext, 'test question');
@ -355,25 +275,24 @@ describe('btwCommand', () => {
});
it('should cancel previous btw when starting a new one', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
mockRunForkedAgent.mockResolvedValue({
text: 'answer',
usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 },
});
await btwCommand.action!(mockContext, 'first question');
// cancelBtw should have been called to clean up any previous btw
expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(1);
// Second btw call
await btwCommand.action!(mockContext, 'second question');
// cancelBtw called again for the second invocation
expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(2);
});
it('should return fallback text when response has no parts', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [] } }],
it('should return fallback text when text is null', async () => {
mockRunForkedAgent.mockResolvedValue({
text: null,
usage: { inputTokens: 5, outputTokens: 0, cacheHitTokens: 0 },
});
await btwCommand.action!(mockContext, 'test question');
@ -390,8 +309,9 @@ describe('btwCommand', () => {
});
it('should return void immediately without blocking', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
mockRunForkedAgent.mockResolvedValue({
text: 'answer',
usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 },
});
const result = await btwCommand.action!(mockContext, 'test question');
@ -421,8 +341,9 @@ describe('btwCommand', () => {
});
it('should return info message on success', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'the answer' }] } }],
mockRunForkedAgent.mockResolvedValue({
text: 'the answer',
usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 },
});
const result = await btwCommand.action!(
@ -438,7 +359,7 @@ describe('btwCommand', () => {
});
it('should return error message on failure', async () => {
mockGenerateContent.mockRejectedValue(new Error('network error'));
mockRunForkedAgent.mockRejectedValue(new Error('network error'));
const result = await btwCommand.action!(
nonInteractiveContext,
@ -466,8 +387,9 @@ describe('btwCommand', () => {
});
it('should return stream_messages generator on success', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'streamed answer' }] } }],
mockRunForkedAgent.mockResolvedValue({
text: 'streamed answer',
usage: { inputTokens: 5, outputTokens: 3, cacheHitTokens: 0 },
});
const result = (await btwCommand.action!(acpContext, 'my question')) as {
@ -489,7 +411,7 @@ describe('btwCommand', () => {
});
it('should yield error message on failure', async () => {
mockGenerateContent.mockRejectedValue(new Error('api failure'));
mockRunForkedAgent.mockRejectedValue(new Error('api failure'));
const result = (await btwCommand.action!(acpContext, 'my question')) as {
type: string;

View file

@ -13,12 +13,7 @@ import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import type { HistoryItemBtw } from '../types.js';
import { t } from '../../i18n/index.js';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai';
function makeBtwPromptId(sessionId: string): string {
return `${sessionId}########btw-${Date.now()}`;
}
import { getCacheSafeParams, runForkedAgent } from '@qwen-code/qwen-code-core';
function formatBtwError(error: unknown): string {
return t('Failed to answer btw question: {{error}}', {
@ -27,83 +22,59 @@ function formatBtwError(error: unknown): string {
});
}
// Keep only the most recent history messages to limit token usage for side
// questions. MAX_BTW_HISTORY_MESSAGES caps the number of history Content
// entries included as context before the /btw question is appended.
const MAX_BTW_HISTORY_MESSAGES = 20;
function trimHistory(history: Content[]): Content[] {
if (history.length <= MAX_BTW_HISTORY_MESSAGES) {
return history;
}
// Slice from the end, ensuring we start on a 'user' message so the
// alternating user/model pattern is preserved.
const sliced = history.slice(-MAX_BTW_HISTORY_MESSAGES);
if (sliced[0]?.role === 'model' && sliced.length > 1) {
return sliced.slice(1);
}
return sliced;
/**
* Wrap the user's side question with constraints so the model knows it must
* answer without tools in a single response.
*
* The system-reminder is embedded in the user message rather than overriding
* systemInstruction, because runForkedAgent inherits systemInstruction from
* CacheSafeParams (changing it would bust the prompt cache).
*/
function buildBtwPrompt(question: string): string {
return [
'<system-reminder>',
'This is a side question from the user. Answer directly in a single response.',
'',
'CRITICAL CONSTRAINTS:',
'- You have NO tools available — you cannot read files, run commands, or take any actions.',
'- You can ONLY use information already present in the conversation context.',
'- NEVER promise to look something up or investigate further.',
'- If you do not know the answer, say so.',
'- The main conversation is NOT interrupted; you are a separate, lightweight fork.',
'</system-reminder>',
'',
question,
].join('\n');
}
/**
* Helper to make the ephemeral generateContent call and extract the answer.
* Uses a snapshot of the current conversation history as context.
* Run a side question using runForkedAgent (cache path).
*
* runForkedAgent with cacheSafeParams shares the main conversation's
* CacheSafeParams (systemInstruction + history) so the fork sees the full
* conversation context and benefits from prompt-cache hits. Tools are denied
* at the per-request level (NO_TOOLS) single-turn, text-only.
*/
async function askBtw(
geminiClient: GeminiClient,
model: string,
context: CommandContext,
question: string,
abortSignal: AbortSignal,
promptId: string,
): Promise<string> {
const history = trimHistory(geminiClient.getHistory(true));
const { config } = context.services;
if (!config) throw new Error('Config not loaded');
// Side-question guidance sent as a user message (not a system instruction).
// Inspired by Claude Code's design:
// - Emphasizes direct answering without tools
// - Clarifies the isolated nature of the side question
// - Prevents the model from promising actions it can't take
const response = await geminiClient.generateContent(
[
...history,
{
role: 'user',
parts: [
{
text: `[This is a side question - answer directly and concisely.
const cacheSafeParams = getCacheSafeParams();
if (!cacheSafeParams)
throw new Error(t('No conversation context available for /btw'));
IMPORTANT:
- You are a separate, lightweight agent spawned to answer this one question
- The main conversation continues independently in the background
- Do NOT reference being interrupted or what you were "previously doing"
CRITICAL CONSTRAINTS:
- You have NO tools available - you cannot read files, run commands, search, or take any actions
- This is a one-off response in a single turn
- You can ONLY provide information based on what you already know from the conversation context
- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
- If you don't know the answer, say so - do not offer to look it up or investigate
Simply answer the question directly with the information you have.]
${question}`,
},
],
},
],
{},
const result = await runForkedAgent({
config,
userMessage: buildBtwPrompt(question),
cacheSafeParams,
abortSignal,
model,
promptId,
);
});
const parts = response.candidates?.[0]?.content?.parts;
return (
parts
?.map((part) => part.text)
.filter((text): text is string => typeof text === 'string')
.join('') || t('No response received.')
);
return result.text || t('No response received.');
}
export const btwCommand: SlashCommand = {
@ -141,21 +112,8 @@ export const btwCommand: SlashCommand = {
};
}
const geminiClient = config.getGeminiClient();
const model = config.getModel();
const sessionId = config.getSessionId();
if (!model) {
return {
type: 'message',
messageType: 'error',
content: t('No model configured.'),
};
}
// ACP mode: return a stream_messages async generator
if (executionMode === 'acp') {
const btwPromptId = makeBtwPromptId(sessionId);
const messages = async function* () {
try {
yield {
@ -163,13 +121,7 @@ export const btwCommand: SlashCommand = {
content: t('Thinking...'),
};
const answer = await askBtw(
geminiClient,
model,
question,
abortSignal,
btwPromptId,
);
const answer = await askBtw(context, question, abortSignal);
yield {
messageType: 'info' as const,
@ -189,14 +141,7 @@ export const btwCommand: SlashCommand = {
// Non-interactive mode: return a simple message result
if (executionMode === 'non_interactive') {
try {
const btwPromptId = makeBtwPromptId(sessionId);
const answer = await askBtw(
geminiClient,
model,
question,
abortSignal,
btwPromptId,
);
const answer = await askBtw(context, question, abortSignal);
return {
type: 'message',
messageType: 'info',
@ -231,10 +176,9 @@ export const btwCommand: SlashCommand = {
};
ui.setBtwItem(pendingItem);
// Fire-and-forget: run the API call in the background so the main
// Fire-and-forget: runForkedAgent runs in the background so the main
// conversation is not blocked while waiting for the btw answer.
const btwPromptId = makeBtwPromptId(sessionId);
void askBtw(geminiClient, model, question, btwSignal, btwPromptId)
void askBtw(context, question, btwSignal)
.then((answer) => {
if (btwSignal.aborted) return;

View file

@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
getAutoMemoryRoot,
getProjectHash,
QWEN_DIR,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const dreamCommand: SlashCommand = {
name: 'dream',
get description() {
return t('Consolidate managed auto-memory topic files.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const config = context.services.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const projectRoot = config.getProjectRoot();
const memoryRoot = getAutoMemoryRoot(projectRoot);
const projectHash = getProjectHash(projectRoot);
const transcriptDir = `${QWEN_DIR}/tmp/${projectHash}/chats`;
const prompt = config
.getMemoryManager()
.buildConsolidationPrompt(memoryRoot, transcriptDir);
return {
type: 'submit_prompt',
content: prompt,
onComplete: async () => {
await config
.getMemoryManager()
.writeDreamManualRun(projectRoot, config.getSessionId());
},
};
},
};

View file

@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { t } from '../../i18n/index.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const forgetCommand: SlashCommand = {
name: 'forget',
get description() {
return t('Remove matching entries from managed auto-memory.');
},
kind: CommandKind.BUILT_IN,
action: async (context, args) => {
const query = args.trim();
if (!query) {
return {
type: 'message',
messageType: 'error',
content: t('Usage: /forget <memory text to remove>'),
};
}
const config = context.services.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const selection = await config
.getMemoryManager()
.selectForgetCandidates(config.getProjectRoot(), query, { config });
const result = await config
.getMemoryManager()
.forgetMatches(config.getProjectRoot(), selection.matches);
return {
type: 'message',
messageType: 'info',
content:
result.systemMessage ??
t('No managed auto-memory entries matched: {{query}}', { query }),
};
},
};

View file

@ -4,518 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { describe, expect, it } from 'vitest';
import { memoryCommand } from './memoryCommand.js';
import type { SlashCommand, CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
getErrorMessage,
loadServerHierarchicalMemory,
QWEN_DIR,
setGeminiMdFilename,
type FileDiscoveryService,
type LoadServerHierarchicalMemoryResponse,
} from '@qwen-code/qwen-code-core';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
getErrorMessage: vi.fn((error: unknown) => {
if (error instanceof Error) return error.message;
return String(error);
}),
loadServerHierarchicalMemory: vi.fn(),
};
});
vi.mock('node:fs/promises', () => {
const readFile = vi.fn();
return {
readFile,
default: {
readFile,
},
};
});
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
const mockReadFile = readFile as unknown as Mock;
describe('memoryCommand', () => {
let mockContext: CommandContext;
const getSubCommand = (name: 'show' | 'add' | 'refresh'): SlashCommand => {
const subCommand = memoryCommand.subCommands?.find(
(cmd) => cmd.name === name,
);
if (!subCommand) {
throw new Error(`/memory ${name} command not found.`);
}
return subCommand;
};
describe('/memory show', () => {
let showCommand: SlashCommand;
let mockGetUserMemory: Mock;
let mockGetGeminiMdFileCount: Mock;
beforeEach(() => {
setGeminiMdFilename('QWEN.md');
mockReadFile.mockReset();
vi.restoreAllMocks();
showCommand = getSubCommand('show');
mockGetUserMemory = vi.fn();
mockGetGeminiMdFileCount = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
getUserMemory: mockGetUserMemory,
getGeminiMdFileCount: mockGetGeminiMdFileCount,
},
},
});
it('opens the memory dialog in interactive mode', async () => {
const context = createMockCommandContext({
executionMode: 'interactive',
});
it('should display a message if memory is empty', async () => {
if (!showCommand.action) throw new Error('Command has no action');
const result = await memoryCommand.action?.(context, '');
mockGetUserMemory.mockReturnValue('');
mockGetGeminiMdFileCount.mockReturnValue(0);
await showCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Memory is currently empty.',
},
expect.any(Number),
);
});
it('should display the memory content and file count if it exists', async () => {
if (!showCommand.action) throw new Error('Command has no action');
const memoryContent = 'This is a test memory.';
mockGetUserMemory.mockReturnValue(memoryContent);
mockGetGeminiMdFileCount.mockReturnValue(1);
await showCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Current memory content from 1 file(s):\n\n---\n${memoryContent}\n---`,
},
expect.any(Number),
);
});
it('should show project memory from the configured context file', async () => {
const projectCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--project',
);
if (!projectCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename('AGENTS.md');
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
mockReadFile.mockResolvedValue('project memory');
await projectCommand.action(mockContext, '');
const expectedProjectPath = path.join('/test/project', 'AGENTS.md');
expect(mockReadFile).toHaveBeenCalledWith(expectedProjectPath, 'utf-8');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining(expectedProjectPath),
},
expect.any(Number),
);
});
it('should show global memory from the configured context file', async () => {
const globalCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--global',
);
if (!globalCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename('AGENTS.md');
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
mockReadFile.mockResolvedValue('global memory');
await globalCommand.action(mockContext, '');
const expectedGlobalPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
expect(mockReadFile).toHaveBeenCalledWith(expectedGlobalPath, 'utf-8');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining('Global memory content'),
},
expect.any(Number),
);
});
it('should fall back to AGENTS.md when QWEN.md does not exist for --project', async () => {
const projectCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--project',
);
if (!projectCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.endsWith('AGENTS.md')) return 'agents memory content';
throw new Error('ENOENT');
});
await projectCommand.action(mockContext, '');
const expectedPath = path.join('/test/project', 'AGENTS.md');
expect(mockReadFile).toHaveBeenCalledWith(expectedPath, 'utf-8');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining('agents memory content'),
},
expect.any(Number),
);
});
it('should fall back to AGENTS.md when QWEN.md does not exist for --global', async () => {
const globalCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--global',
);
if (!globalCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.endsWith('AGENTS.md')) return 'global agents memory';
throw new Error('ENOENT');
});
await globalCommand.action(mockContext, '');
const expectedPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
expect(mockReadFile).toHaveBeenCalledWith(expectedPath, 'utf-8');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining('global agents memory'),
},
expect.any(Number),
);
});
it('should show content from both QWEN.md and AGENTS.md for --project when both exist', async () => {
const projectCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--project',
);
if (!projectCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.endsWith('QWEN.md')) return 'qwen memory';
if (filePath.endsWith('AGENTS.md')) return 'agents memory';
throw new Error('ENOENT');
});
await projectCommand.action(mockContext, '');
expect(mockReadFile).toHaveBeenCalledWith(
path.join('/test/project', 'QWEN.md'),
'utf-8',
);
expect(mockReadFile).toHaveBeenCalledWith(
path.join('/test/project', 'AGENTS.md'),
'utf-8',
);
const addItemCall = (mockContext.ui.addItem as Mock).mock.calls[0][0];
expect(addItemCall.text).toContain('qwen memory');
expect(addItemCall.text).toContain('agents memory');
});
it('should show content from both files for --global when both exist', async () => {
const globalCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--global',
);
if (!globalCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.endsWith('QWEN.md')) return 'global qwen memory';
if (filePath.endsWith('AGENTS.md')) return 'global agents memory';
throw new Error('ENOENT');
});
await globalCommand.action(mockContext, '');
expect(mockReadFile).toHaveBeenCalledWith(
path.join('/home/user', QWEN_DIR, 'QWEN.md'),
'utf-8',
);
expect(mockReadFile).toHaveBeenCalledWith(
path.join('/home/user', QWEN_DIR, 'AGENTS.md'),
'utf-8',
);
const addItemCall = (mockContext.ui.addItem as Mock).mock.calls[0][0];
expect(addItemCall.text).toContain('global qwen memory');
expect(addItemCall.text).toContain('global agents memory');
expect(result).toEqual({
type: 'dialog',
dialog: 'memory',
});
});
describe('/memory add', () => {
let addCommand: SlashCommand;
beforeEach(() => {
addCommand = getSubCommand('add');
mockContext = createMockCommandContext();
it('returns a non-interactive fallback message outside the interactive UI', async () => {
const context = createMockCommandContext({
executionMode: 'non_interactive',
});
it('should return an error message if no arguments are provided', () => {
if (!addCommand.action) throw new Error('Command has no action');
const result = await memoryCommand.action?.(context, '');
const result = addCommand.action(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /memory add [--global|--project] <text to remember>',
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
it('should return a tool action and add an info message when arguments are provided', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this';
const result = addCommand.action(mockContext, ` ${fact} `);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory : "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact },
});
});
it('should handle --global flag and add scope to tool args', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this globally';
const result = addCommand.action(mockContext, `--global ${fact}`);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory (global): "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact, scope: 'global' },
});
});
it('should handle --project flag and add scope to tool args', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this for project';
const result = addCommand.action(mockContext, `--project ${fact}`);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory (project): "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact, scope: 'project' },
});
});
it('should return error if flag is provided but no fact follows', () => {
if (!addCommand.action) throw new Error('Command has no action');
const result = addCommand.action(mockContext, '--global ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /memory add [--global|--project] <text to remember>',
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
});
describe('/memory refresh', () => {
let refreshCommand: SlashCommand;
let mockSetUserMemory: Mock;
let mockSetGeminiMdFileCount: Mock;
beforeEach(() => {
refreshCommand = getSubCommand('refresh');
mockSetUserMemory = vi.fn();
mockSetGeminiMdFileCount = vi.fn();
const mockConfig = {
setUserMemory: mockSetUserMemory,
setGeminiMdFileCount: mockSetGeminiMdFileCount,
getWorkingDir: () => '/test/dir',
getDebugMode: () => false,
getFileService: () => ({}) as FileDiscoveryService,
getExtensionContextFilePaths: () => [],
shouldLoadMemoryFromIncludeDirectories: () => false,
getWorkspaceContext: () => ({
getDirectories: () => [],
}),
getFileFilteringOptions: () => ({
ignore: [],
include: [],
}),
getFolderTrust: () => false,
};
mockContext = createMockCommandContext({
services: {
config: mockConfig,
settings: {
merged: {},
} as LoadedSettings,
},
});
mockLoadServerHierarchicalMemory.mockClear();
});
it('should display success message when memory is refreshed with content', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const refreshResult: LoadServerHierarchicalMemoryResponse = {
memoryContent: 'new memory content',
fileCount: 2,
};
mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult);
await refreshCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
},
expect.any(Number),
);
expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).toHaveBeenCalledWith(
refreshResult.memoryContent,
);
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(
refreshResult.fileCount,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
},
expect.any(Number),
);
});
it('should display success message when memory is refreshed with no content', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const refreshResult = { memoryContent: '', fileCount: 0 };
mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult);
await refreshCommand.action(mockContext, '');
expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).toHaveBeenCalledWith('');
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(0);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Memory refreshed successfully. No memory content found.',
},
expect.any(Number),
);
});
it('should display an error message if refreshing fails', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const error = new Error('Failed to read memory files.');
mockLoadServerHierarchicalMemory.mockRejectedValue(error);
await refreshCommand.action(mockContext, '');
expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).not.toHaveBeenCalled();
expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Error refreshing memory: ${error.message}`,
},
expect.any(Number),
);
expect(getErrorMessage).toHaveBeenCalledWith(error);
});
it('should not throw if config service is unavailable', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
const nullConfigContext = createMockCommandContext({
services: { config: null },
});
await expect(
refreshCommand.action(nullConfigContext, ''),
).resolves.toBeUndefined();
expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
},
expect.any(Number),
);
expect(loadServerHierarchicalMemory).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'The memory manager is only available in the interactive UI. In non-interactive mode, open the user or project memory files directly.',
});
});
});

View file

@ -4,349 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
getErrorMessage,
getAllGeminiMdFilenames,
loadServerHierarchicalMemory,
QWEN_DIR,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs/promises';
import { MessageType } from '../types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
/**
* Read all existing memory files from the configured filenames in a directory.
* Returns an array of found files with their paths and contents.
*/
async function findAllExistingMemoryFiles(
dir: string,
): Promise<Array<{ filePath: string; content: string }>> {
const results: Array<{ filePath: string; content: string }> = [];
for (const filename of getAllGeminiMdFilenames()) {
const filePath = path.join(dir, filename);
try {
const content = await fs.readFile(filePath, 'utf-8');
if (content.trim().length > 0) {
results.push({ filePath, content });
}
} catch {
// File doesn't exist, try next
}
}
return results;
}
export const memoryCommand: SlashCommand = {
name: 'memory',
get description() {
return t('Commands for interacting with memory.');
return t('Open the memory manager.');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'show',
get description() {
return t('Show the current memory contents.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const memoryContent = context.services.config?.getUserMemory() || '';
const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
action: async (context) => {
const executionMode = context.executionMode ?? 'interactive';
const messageContent =
memoryContent.length > 0
? `${t('Current memory content from {{count}} file(s):', { count: String(fileCount) })}\n\n---\n${memoryContent}\n---`
: t('Memory is currently empty.');
if (executionMode === 'interactive') {
return {
type: 'dialog',
dialog: 'memory',
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: messageContent,
},
Date.now(),
);
},
subCommands: [
{
name: '--project',
get description() {
return t('Show project-level memory contents.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const workingDir =
context.services.config?.getWorkingDir?.() ?? process.cwd();
const results = await findAllExistingMemoryFiles(workingDir);
if (results.length > 0) {
const combined = results
.map((r) =>
t(
'Project memory content from {{path}}:\n\n---\n{{content}}\n---',
{ path: r.filePath, content: r.content },
),
)
.join('\n\n');
context.ui.addItem(
{
type: MessageType.INFO,
text: combined,
},
Date.now(),
);
} else {
context.ui.addItem(
{
type: MessageType.INFO,
text: t(
'Project memory file not found or is currently empty.',
),
},
Date.now(),
);
}
},
},
{
name: '--global',
get description() {
return t('Show global memory contents.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const globalDir = path.join(os.homedir(), QWEN_DIR);
const results = await findAllExistingMemoryFiles(globalDir);
if (results.length > 0) {
const combined = results
.map((r) =>
t('Global memory content:\n\n---\n{{content}}\n---', {
content: r.content,
}),
)
.join('\n\n');
context.ui.addItem(
{
type: MessageType.INFO,
text: combined,
},
Date.now(),
);
} else {
context.ui.addItem(
{
type: MessageType.INFO,
text: t(
'Global memory file not found or is currently empty.',
),
},
Date.now(),
);
}
},
},
],
},
{
name: 'add',
get description() {
return t(
'Add content to the memory. Use --global for global memory or --project for project memory.',
);
},
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
}
const trimmedArgs = args.trim();
let scope: 'global' | 'project' | undefined;
let fact: string;
// Check for scope flags
if (trimmedArgs.startsWith('--global ')) {
scope = 'global';
fact = trimmedArgs.substring('--global '.length).trim();
} else if (trimmedArgs.startsWith('--project ')) {
scope = 'project';
fact = trimmedArgs.substring('--project '.length).trim();
} else if (trimmedArgs === '--global' || trimmedArgs === '--project') {
// Flag provided but no text after it
return {
type: 'message',
messageType: 'error',
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
} else {
// No scope specified, will be handled by the tool
fact = trimmedArgs;
}
if (!fact || fact.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
}
const scopeText = scope ? `(${scope})` : '';
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Attempting to save to memory {{scope}}: "{{fact}}"', {
scope: scopeText,
fact,
}),
},
Date.now(),
);
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: scope ? { fact, scope } : { fact },
};
},
subCommands: [
{
name: '--project',
get description() {
return t('Add content to project-level memory.');
},
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: t('Usage: /memory add --project <text to remember>'),
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Attempting to save to project memory: "{{text}}"', {
text: args.trim(),
}),
},
Date.now(),
);
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim(), scope: 'project' },
};
},
},
{
name: '--global',
get description() {
return t('Add content to global memory.');
},
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: t('Usage: /memory add --global <text to remember>'),
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Attempting to save to global memory: "{{text}}"', {
text: args.trim(),
}),
},
Date.now(),
);
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim(), scope: 'global' },
};
},
},
],
},
{
name: 'refresh',
get description() {
return t('Refresh the memory from the source.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Refreshing memory from source files...'),
},
Date.now(),
);
try {
const config = context.services.config;
if (config) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
config.shouldLoadMemoryFromIncludeDirectories()
? config.getWorkspaceContext().getDirectories()
: [],
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
const successMessage =
memoryContent.length > 0
? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
: 'Memory refreshed successfully. No memory content found.';
context.ui.addItem(
{
type: MessageType.INFO,
text: successMessage,
},
Date.now(),
);
}
} catch (error) {
const errorMessage = getErrorMessage(error);
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Error refreshing memory: ${errorMessage}`,
},
Date.now(),
);
}
},
},
],
return {
type: 'message',
messageType: 'info',
content: t(
'The memory manager is only available in the interactive UI. In non-interactive mode, open the user or project memory files directly.',
),
};
},
};

View file

@ -72,6 +72,9 @@ export const modelCommand: SlashCommand = {
'fastModel',
modelName,
);
// Sync the runtime Config so forked agents pick up the change immediately
// without requiring a restart.
config.setFastModel(modelName);
return {
type: 'message',
messageType: 'info',

View file

@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { getAutoMemoryRoot } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
export const rememberCommand: SlashCommand = {
name: 'remember',
get description() {
return t('Save a durable memory to the memory system.');
},
kind: CommandKind.BUILT_IN,
action: (context: CommandContext, args): SlashCommandActionReturn | void => {
const fact = args.trim();
if (!fact) {
return {
type: 'message',
messageType: 'error',
content: t('Usage: /remember <text to remember>'),
};
}
const config = context.services.config;
const useManagedMemory = config?.getManagedAutoMemoryEnabled() ?? false;
if (useManagedMemory) {
// In managed auto-memory mode the save_memory tool is not registered.
// Submit a prompt so the main agent writes the per-entry file directly,
// choosing the appropriate type (user / feedback / project / reference)
// based on the content, following the instructions in buildManagedAutoMemoryPrompt.
const memoryDir = config
? getAutoMemoryRoot(config.getProjectRoot())
: undefined;
const dirHint = memoryDir ? ` Save it to \`${memoryDir}\`.` : '';
return {
type: 'submit_prompt',
content: `Please save the following to your memory system.${dirHint} Choose the most appropriate memory type (user, feedback, project, or reference) based on the content:\n\n${fact}`,
};
}
// Managed auto-memory is disabled: ask the agent to save to QWEN.md
// using its native file tools. We do not call save_memory because that
// tool was removed.
return {
type: 'submit_prompt',
content: `Please save the following fact to memory (e.g. append to QWEN.md in the project root):\n\n${fact}`,
};
},
};

View file

@ -156,6 +156,7 @@ export interface OpenDialogActionReturn {
| 'theme'
| 'editor'
| 'settings'
| 'memory'
| 'model'
| 'fast-model'
| 'subagent_create'
@ -186,6 +187,8 @@ export interface LoadHistoryActionReturn {
export interface SubmitPromptActionReturn {
type: 'submit_prompt';
content: PartListUnion;
/** Optional callback invoked after the agent turn completes successfully. */
onComplete?: () => Promise<void>;
}
/**