Full architecture of the auto-memory system in Qwen Code. This covers how the harness learns about the user, project, and tool usage over time — persisting knowledge across sessions.
packages/core/src/memory/
├── manager.ts ← Single public API (MemoryManager)
├── types.ts ← AutoMemoryType, metadata types
├── const.ts ← Filename constants (QWEN.md, MEMORY.md)
├── paths.ts ← Disk layout: project/user memory roots
├── store.ts ← Scaffold creation (mkdir + seed files)
├── scan.ts ← Read & parse memory files from disk
├── recall.ts ← Find relevant memories for a query
├── relevanceSelector.ts ← LLM-based relevance filtering
├── extract.ts ← Extract memories from conversation history
├── extractionAgentPlanner.ts ← Forked agent that does the extraction
├── dream.ts ← Consolidate/merge/clean memory files
├── dreamAgentPlanner.ts ← Forked agent that dreams (consolidation)
├── forget.ts ← Remove stale/irrelevant memories
├── indexer.ts ← Rebuild MEMORY.md index from topic files
├── entries.ts ← Parse/write individual memory entries
├── prompt.ts ← System prompt blocks for memory instructions
├── memoryAge.ts ← Staleness calculation + display
├── pending-skills.ts ← Auto-skill confirmation staging
├── skillReviewAgentPlanner.ts ← Auto-skill review via forked agent
└── status.ts ← Memory status query for /memory command
Four topic types (frontmatter type field in each .md file):
| Type | Scope | Purpose |
|---|---|---|
user |
Always cross-project | Who the user is, preferences, expertise |
feedback |
Default user; project if team convention | Corrections + confirmations from user |
project |
Always project-only | Deadlines, goals, decisions, context |
reference |
Default project; user if global | External system pointers (dashboards, tickets) |
~/.qwen/projects/<sanitized-git-root>/memory/
├── MEMORY.md ← Index file (loaded into system prompt)
├── user.md ← Topic files (one per type)
├── feedback.md
├── project.md
└── reference.md
~/.qwen/projects/<sanitized-git-root>/
├── meta.json ← Dream/extract timestamps, touched topics
├── extract-cursor.json ← { sessionId, processedOffset } for incremental extraction
└── consolidation.lock ← PID-based lock for dream mutual exclusion
Or with QWEN_CODE_MEMORY_LOCAL=1:
<projectRoot>/.qwen/memory/
├── MEMORY.md
├── user.md
├── ...
~/.qwen/memories/
├── MEMORY.md
├── user.md
├── feedback.md
├── project.md
└── reference.md
Each file uses YAML frontmatter:
---
name: <short-kebab-case-slug>
description: <one-line summary — used for relevance matching>
type: user | feedback | project | reference
---
<content — for feedback/project: rule → **Why:** → **How to apply:**>MEMORY.md index file format:
- [Title](file.md) — one-line hook
- [Another](path/to/file.md) — descriptionIndex limits: 200 lines, 25KB, 150 chars per line. Exceeding these truncates with warning.
Entry point: MemoryManager.recall(projectRoot, query, options) → recall.ts
User query arrives
│
▼
scanAutoMemoryTopicDocuments(projectRoot) ← read + parse all topic .md files
scanUserAutoMemoryTopicDocuments() ← read + parse user-level topic files
│
▼
selectRelevantAutoMemoryDocuments(query, docs, limit=5)
│
├─ Tokenize query → keywords
├─ Score each doc: keyword matches + type keyword hits + has-body bonus
├─ Filter score > 0
├─ Sort by score desc → then type
└─ Take top 5
│
▼
(Optional) selectRelevantAutoMemoryDocumentsByModel() ← LLM-based re-ranking
│ relevanceSelector.ts: sends recall candidates to fast model for relevance check
│
▼
buildRelevantAutoMemoryPrompt(docs)
│
├─ Truncate each body to 1200 chars
├─ Add staleness note if > 7 days old
└─ Format as "<system-reminder>## Relevant memory\n..."
│
▼
Injected into system reminders block before LLM sees prompt
Recall scoring (recall.ts:137-161):
- Each keyword match in doc → +2
- Type keyword match (e.g., "feedback" matches
feedbacktype) → +1 - Body has content (not empty placeholder) → +1
isActiveToolUsageMemory: suppresses low-signal "tool usage reference" docs that mention recently-used tools but aren't durable (credential, warning, etc.)
Pre-computation markers (recall.ts:23-56):
ACTIVE_TOOL_USAGE_MEMORY_MARKERS— high-churn: api docs, field mapping, tool schema, parameter schema → suppressed if matching recent toolDURABLE_ACTIVE_TOOL_MEMORY_MARKERS— low-churn: credential, gotcha, owner, warning, workaround → always kept
Entry point: MemoryManager.scheduleExtract(params) → extract.ts
Called from GeminiClient.sendMessageStream after the turn loop yields Finished. The extraction agent is a forked subagent — it runs in a separate context with its own model call, writing directly to memory files.
scheduleExtract({ projectRoot, sessionId, history })
│
├─ historyWritesToMemory? → skip (model already wrote to memory directly)
├─ extractRunning for this project? → queue (supersede existing queued)
│
▼
runExtract(projectRoot)
│
├─ ensureAutoMemoryScaffold() ← create dirs + seed empty files
├─ readExtractCursor() ← get last processed offset
├─ Skip if no new user messages in unprocessed slice
│
▼
runAutoMemoryExtractionByAgent(config, projectRoot)
│ extractionAgentPlanner.ts
│
├─ getCacheSafeParams() ← reuse conversation cache params (saves tokens)
├─ buildTopicSummaryBlock() ← scan existing memory files → summaries
├─ createMemoryScopedAgentConfig() ← restrict permissions to memory paths
│
▼
runForkedAgent({
name: 'managed-auto-memory-extractor',
maxTurns: 5,
maxTimeMinutes: 2,
tools: [Read, Grep, Glob, LS, Shell, Write, Edit],
taskPrompt: "Extract learnings from the conversation into memory files..."
})
│
├─ Forked agent reads topic files, conversation history
├─ Decides what to save (using TYPES_SECTION_INDIVIDUAL guidance)
├─ Writes new/updated .md files
├─ Updates MEMORY.md index entries
└─ Returns { filesTouched[], status }
│
▼
rebuildManagedAutoMemoryIndex() ← indexer.ts: re-scan all topic files
│ and regenerate MEMORY.md
▼
writeExtractCursor() ← advance cursor to end of processed history
bumpMetadata() ← update lastExtractionAt, touchedTopics
Skipping conditions:
- Main agent already wrote to memory files this turn (
memory_tool) - Already running for this project (
already_running) — queues instead - No new user messages since last extract
Concurrency: One extract per project at a time. Trailing requests queue — newest supersedes.
Entry point: MemoryManager.scheduleDream(params) → dream.ts
Dream = consolidate, deduplicate, and clean up memory files. Runs less frequently than extract.
scheduleDream({ projectRoot, sessionId })
│
├─ Disabled? → skip
├─ Same session already dreamed? → skip
├─ < minHoursBetweenDreams (default 24h)? → skip
├─ < minSessionsBetweenDreams (default 5)? → skip
├─ No new session files since last scan? → skip
├─ Lock exists (another dream running)? → skip
│
▼
runDream(projectRoot)
│
├─ acquireDreamLock() ← write PID to consolidation.lock
│
▼
planManagedAutoMemoryDreamByAgent(config, projectRoot)
│ dreamAgentPlanner.ts
│
└─ Forked agent with task: "Consolidate memory files.
Deduplicate entries. Merge related topics. Remove stale info.
Rebuild MEMORY.md index."
│
▼
rebuildManagedAutoMemoryIndex() ← re-scan all topic files → regenerate MEMORY.md
updateDreamMetadata() ← bump lastDreamAt, reset session counter
releaseDreamLock() ← remove consolidation.lock
Throttling:
DEFAULT_AUTO_DREAM_MIN_HOURS = 24— at most once per dayDEFAULT_AUTO_DREAM_MIN_SESSIONS = 5— at least 5 sessions between dreams- Session scan interval: 10 minutes (scans
~/.qwen/projects/<root>/chats/*.jsonlfor new sessions by mtime) - Lock: PID-based with 1-hour staleness timeout
Entry point: MemoryManager.forget(projectRoot, query, options) → forget.ts
Called when user says "forget X" — uses a side query (cheap LLM call) to match entries.
forgetManagedAutoMemoryEntries(projectRoot, query, config)
│
├─ Parse all topic files → extract individual entries
├─ Heuristic: exact keyword match against combined entry text (title + description + body)
├─ If ambiguous → run forget selection side query (cheap model call)
│ → Returns list of entry IDs to remove
├─ Remove matched entries from topic files
├─ rebuildManagedAutoMemoryIndex()
└─ Return { removedEntries[], touchedTopics[] }
Entry point: MemoryManager.getStatus(projectRoot) → status.ts
Returns ManagedAutoMemoryStatus — used by /memory slash command:
- When last extraction ran
- Which topics were touched
- Pending tasks (extract/dream running or queued)
- Task history
Memory is injected into every conversation's system prompt via buildManagedAutoMemoryPrompt() (prompt.ts).
What the model sees:
- Memory instructions — how memory works, the 4 types, when to save, what NOT to save
- MEMORY.md index — truncated list of all memory files (one line each)
- Relevant memory (per-turn) — top 5 matching documents from recall, injected as
<system-reminder>block
Dual-directory mode: When both project and user memory exist, the prompt teaches:
- USER memory dir (
~/.qwen/memories/) — cross-project, durable user facts - PROJECT memory dir (
~/.qwen/projects/<root>/memory/) — this project only <scope>guidance per type → model decides which directory for each save
File: <stateDir>/consolidation.lock
- Created with
O_EXCL(fails if exists) → mutual exclusion - Contains PID of holder process
- If PID dead → lock considered stale (removed)
- If mtime > 1 hour → stale (removed even if PID alive)
- Released by deleting the file
In-memory only (no disk lock):
extractRunning: Set<projectRoot>— tracks active extractsextractQueued: Map<projectRoot, { taskId, params }>— one queued per project- New extract while running → supersedes existing queued (newest conversation wins)
Separate from memory extraction but managed by MemoryManager.
Trigger: After AUTO_SKILL_THRESHOLD (default 20) tool calls in a session.
Flow:
scheduleSkillReview()— checks threshold, runs forked agent- Forked agent analyzes tool usage patterns, proposes skill files
- If
confirmBeforePersistenabled → staged to.qwen/memory/pending-skills/ - User confirms/discards via
acceptPendingSkill()/rejectPendingSkill()
SESSION START
│
├─ ensureAutoMemoryScaffold() ← create dirs if first time
├─ MEMORY.md loaded into system prompt
│
▼
EVERY USER TURN
│
├─ recall(query) → find relevant memories → inject into prompt
├─ LLM turn(s) execute
│
▼
AFTER TURN COMPLETES
│
├─ scheduleExtract(history) → forked agent writes new memories
│ │
│ ├─ Skip if: memory_tool, no_new_messages, already_running
│ └─ Run: read existing → plan changes → write files → rebuild index
│
├─ scheduleDream(sessionId) → periodic consolidation
│ │
│ └─ Throttled: 24h min, 5 sessions min, locked
│
└─ scheduleSkillReview() → auto-skill detection
│
└─ Throttled: tool call count threshold, per-session
SESSION END
│
└─ drain() — wait for in-flight memory tasks to complete
-
Forked agents for extraction — memory writes happen in isolated subagent context. Main conversation doesn't see the memory agent's tool calls. This keeps the main conversation clean and prevents the memory extraction from polluting the user's chat history.
-
Incremental cursor — extract only processes new messages since last extraction. Cursor stored on disk at
extract-cursor.json. Handles history shrinkage (compression) by re-extracting from offset 0 if cursor is past history length. -
Async prefetch for recall — recall runs as non-blocking prefetch at start of UserQuery turn. If settled by the time the main LLM request is assembled, memory is injected. Otherwise, it's consumed on the first ToolResult turn. This prevents memory recall latency from delaying the first response.
-
Topic files + index pattern — every memory is its own
.mdfile.MEMORY.mdis a regeneratable index. This separation means the model always sees the index in its system prompt, and individual file bodies are only pulled in when recall scores them as relevant. -
Dual scope (user + project) — user-level memory (
~/.qwen/memories/) is shared across all projects. Project-level memory is scoped to git root. The model decides which scope to use per save based on per-type<scope>guidance. -
Lock-based dream mutual exclusion — prevents concurrent consolidation across sessions. PID-based with graceful stale detection.