Skip to content

Instantly share code, notes, and snippets.

@quietcricket
Created July 6, 2026 09:25
Show Gist options
  • Select an option

  • Save quietcricket/c1674d15f0b6996c2f0ee87d86aad313 to your computer and use it in GitHub Desktop.

Select an option

Save quietcricket/c1674d15f0b6996c2f0ee87d86aad313 to your computer and use it in GitHub Desktop.
harness-study

Memory Subsystem

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.


Files Involved

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

Memory Types

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)

Disk Layout

Project Memory

~/.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
├── ...

User Memory (Cross-Project)

~/.qwen/memories/
├── MEMORY.md
├── user.md
├── feedback.md
├── project.md
└── reference.md

Memory File Format

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) — description

Index limits: 200 lines, 25KB, 150 chars per line. Exceeding these truncates with warning.


Core Operations

1. Recall (Every UserQuery Turn)

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 feedback type) → +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 tool
  • DURABLE_ACTIVE_TOOL_MEMORY_MARKERS — low-churn: credential, gotcha, owner, warning, workaround → always kept

2. Extract (After Every UserQuery Turn)

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.

3. Dream (Periodic Consolidation)

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 day
  • DEFAULT_AUTO_DREAM_MIN_SESSIONS = 5 — at least 5 sessions between dreams
  • Session scan interval: 10 minutes (scans ~/.qwen/projects/<root>/chats/*.jsonl for new sessions by mtime)
  • Lock: PID-based with 1-hour staleness timeout

4. Forget

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[] }

5. Get Status

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

System Prompt Integration

Memory is injected into every conversation's system prompt via buildManagedAutoMemoryPrompt() (prompt.ts).

What the model sees:

  1. Memory instructions — how memory works, the 4 types, when to save, what NOT to save
  2. MEMORY.md index — truncated list of all memory files (one line each)
  3. 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

Locking & Concurrency

Dream Lock

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

Extract Queuing

In-memory only (no disk lock):

  • extractRunning: Set<projectRoot> — tracks active extracts
  • extractQueued: Map<projectRoot, { taskId, params }> — one queued per project
  • New extract while running → supersedes existing queued (newest conversation wins)

Auto-Skill Review

Separate from memory extraction but managed by MemoryManager.

Trigger: After AUTO_SKILL_THRESHOLD (default 20) tool calls in a session.

Flow:

  1. scheduleSkillReview() — checks threshold, runs forked agent
  2. Forked agent analyzes tool usage patterns, proposes skill files
  3. If confirmBeforePersist enabled → staged to .qwen/memory/pending-skills/
  4. User confirms/discards via acceptPendingSkill() / rejectPendingSkill()

Lifecycle Diagram

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

Key Design Decisions

  1. 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.

  2. 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.

  3. 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.

  4. Topic files + index pattern — every memory is its own .md file. MEMORY.md is 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.

  5. 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.

  6. Lock-based dream mutual exclusion — prevents concurrent consolidation across sessions. PID-based with graceful stale detection.

Harness Request Lifecycle

Trace of what happens when user sends a prompt through the Qwen Code harness.

Architecture Overview

packages/core/src/  ← runtime engine (GeminiClient, CoreToolScheduler, hooks, permissions)
packages/cli/src/   ← terminal UI (React/Ink) + CLI entry points

Key files:

  • core/client.ts (2765 lines) — GeminiClient, main orchestrator
  • core/coreToolScheduler.ts (4215 lines) — tool execution pipeline
  • core/turn.ts — single turn: LLM call → parse response
  • core/geminiChat.ts — chat history + API communication
  • tools/tool-registry.ts (874 lines) — tool registration + discovery
  • hooks/hookSystem.ts (724 lines) — hook lifecycle coordination
  • permissions/permission-manager.ts (1153 lines) — rule-based allow/deny/ask
  • core/prompts.ts — system prompt assembly

Full Trace

Step 1: User Input → CLI Entry

Interactive (TUI): App.tsx captures input → calls GeminiClient.sendMessageStream() Non-interactive: nonInteractiveCli.ts → same method

Both paths converge at GeminiClient.sendMessageStream(request, signal, prompt_id).


Step 2: UserPromptSubmit Hook

sendMessageStream (client.ts:1643) — fires before anything else:

UserPromptSubmit hook via MessageBus
  → hook scripts get { prompt: "user text" }
  → hook can: block (stop execution), add context (inject extra text), or pass through
  → if blocked → yield UserPromptSubmitBlocked → return

Skips for: Retry, Cron, Notification, Teammate message types.


Step 3: Memory Recall (Async Prefetch)

MemoryManager.recall(projectRoot, promptText)  // fire-and-forget
  → searches memory files for relevant context
  → returns RelevantAutoMemoryPromptResult { prompt, selectedDocs, strategy }
  → injected as <system-reminder> block before model sees prompt

Runs as prefetch — settles in background. Consumed at two opportunistic points:

  • Zero-wait poll right before LLM call (if settled by then)
  • First ToolResult turn (if still settling)

Step 4: System Reminder Assembly

Before LLM call, assembles <system-reminder> blocks:

  1. Auto-memory context (if prefetch settled)
  2. Plan mode reminder (if ApprovalMode.PLAN)
  3. Arena reminder (if arena session active)
  4. Current date injection (UserQuery only — prevents stale dates across midnight)
  5. Skill/command "now available" reminders (MCP tools registered since startup)

All prepended to user prompt: [systemReminders..., userText]


Step 5: IDE Context Injection

If IDE mode + no pending tool call:
  → getIdeContextParts() — diff of IDE state since last turn
  → prepended to first text part of request

Skipped when a tool call is pending (Qwen API requires functionResponse immediately follow functionCall).


Step 6: Pre-Send Checks

1. microcompactHistoryBeforeSend — idle-based + cumulative-size cleanup
2. MaxSessionTurns check — yield MaxSessionTurns if exceeded
3. SessionTokenLimitExceeded — yield if last prompt > limit
4. Arena control signal check — yield/cancel if arena signals stop

Step 7: Turn Loop Begins

const turn = new Turn(chat, prompt_id)
const resultStream = turn.run(model, requestToSend, signal)

Step 8: Turn.run → GeminiChat.sendMessageStream

Turn.run (turn.ts:332) calls:

const responseStream = await this.chat.sendMessageStream(model, {
  message: req,                    // user content
  config: { abortSignal: signal },
}, prompt_id)

GeminiChat.sendMessageStream does:

  1. Build full system promptgetCoreSystemPrompt() assembles:
    • CLAUDE.md / AGENTS.md content
    • Git status
    • Directory structure
    • Tool descriptions (all registered tools)
    • Skill listings
    • Memory file index (MEMORY.md)
    • Hooks system-reminder (if hooks configured)
    • Custom system prompt (from settings)
  2. Auto-compaction — if context near token limit, compress history before sending
  3. Send to LLM — HTTP streaming request to API
  4. Yield stream events — chunks, thoughts, function calls, retries, compression events

Step 9: Model Response Processing

Turn.run iterates stream events:

Stream Event Yields
retry GeminiEventType.Retry — clears pending state
compressed GeminiEventType.ChatCompressed — auto-compaction fired
chunk (thought) GeminiEventType.Thought — parsed thinking block
chunk (text) GeminiEventType.Content — visible text
chunk (functionCall) GeminiEventType.ToolCallRequest — tool execution needed
chunk (citation) Collected, yielded at end
finishReason GeminiEventType.Finished — turn complete

Step 10: Tool Call → CoreToolScheduler._schedule

When model calls tools, sendMessageStream loop yields ToolCallRequest → CLI/UI calls CoreToolScheduler._schedule(request, signal).

_schedule (coreToolScheduler.ts:1703):

1. Dedupe by callId
2. For each tool call:
   a. Check isToolEnabled (permission deny list)
      → if denied: status='error' with EXECUTION_DENIED
   b. Look up tool in ToolRegistry.ensureTool(canonicalName)
      → if not found: status='error' with TOOL_NOT_REGISTERED
   c. Check truncation safety (reject edits on truncated output)
   d. Create ToolCall with status='validating'
3. Call scheduleBatch()

Step 11: scheduleBatch → Validate → Execute

For each tool call:

1. validatesToolParams (schema validation)
   → fail → report error back to model (model retries with corrected params)
   → RETRY LOOP DETECTED if same tool+error seen 3+ times

2. firePreToolUseHook  ← hook scripts can:
   → modify tool input
   → block execution (with reason)
   → inject additional context
   → request permission decision override

3. Permission check (allow/deny/ask):
   → PermissionManager.evaluatePermissionFlow()
   → Merges: settings.json rules + CLI params + core tool rules
   → Shell commands: parse via shell AST → classify read-only vs destructive
   → Path matching against rule patterns
   → Most restrictive decision wins (deny > ask > default > allow)
   → Auto-mode: evaluate whether auto-approve applies

4. If 'ask' → status='awaiting_approval' → UI confirmation dialog
   → user approves/denies/modifies
   → tools with editor support: opens in user's $EDITOR

5. tool.execute(abortSignal)
   → actual tool runs (Bash, Read, Glob, TaskList, etc.)
   → output captured as ToolResult

6. firePostToolUseHook  ← hook scripts can:
   → inject additional context into tool result
   → trigger notifications

7. Format result → ToolCallResponseInfo
   → truncation if output exceeds limit
   → resultDisplay compaction for history

Step 12: Tool Results → Back to LLM

Tool results formatted as functionResponse parts
  → appended to GeminiChat history
  → sendMessageStream called again (SendMessageType.ToolResult)
  → No UserPromptSubmit hook (ToolResult skips it)
  → Auto-memory may inject (if prefetch settled during tool execution)
  → firePostToolBatchHook after all tools in batch complete
  → Back to Step 8 (Turn.run with tool results as new message)

Step 13: Loop Until Done

Loop continues until one of:

Condition Event
Model responds with text only (no function calls) Finished
sessionTurnCount > MaxSessionTurns MaxSessionTurns
Hard cap MAX_TURNS (100) return Turn
Always-on loop detection (consecutive identical calls, per-turn cap) LoopDetected
Heuristic loop detection (content repetition, action stagnation) LoopDetected
lastPromptTokenCount > sessionTokenLimit SessionTokenLimitExceeded
User cancels (Ctrl-C / Esc) UserCancelled

Step 14: Post-Turn Processing

1. Stop hook fires (after model finishes, if configured)
   → hook can request additional context → triggers another turn (SendMessageType.Hook)
   → capped by STOP_HOOK_BLOCK_CAP (default 3) to prevent infinite loops

2. Auto-memory tasks:
   → "dream" / "extract" — background writes to memory files
   → "auto-skill" — prompt to create skill from repeated tool-use patterns

3. Commit attribution: increment prompt count, snapshot file history

4. IDE state update: mark sent context, prepare diff for next turn

5. fireSessionEndHook (on session close)

6. Telemetry: endInteractionSpan, record token usage

Event Flow Diagram

User types prompt
  │
  ▼
UserPromptSubmit hook ──(block?)──▶ stop
  │
  ▼
Memory recall prefetch (async)
  │
  ▼
Assemble system reminders
  │
  ▼
IDE context injection
  │
  ▼
Pre-send checks (compaction, limits, arena)
  │
  ▼
Turn.run() ───▶ GeminiChat.sendMessageStream()
  │                  │
  │                  ├─ Build system prompt
  │                  ├─ Auto-compaction (if needed)
  │                  ├─ Send to LLM (streaming)
  │                  └─ Yield stream events
  │
  ▼
Process stream events
  │
  ├── Content/Thought ──▶ yield to UI
  │
  ├── ToolCallRequest
  │     │
  │     ▼
  │   CoreToolScheduler._schedule()
  │     │
  │     ├─ Permission check (allow/deny/ask)
  │     ├─ PreToolUse hook
  │     ├─ Confirmation (if ask)
  │     ├─ tool.execute()
  │     ├─ PostToolUse hook
  │     └─ Format result ──▶ back to LLM (step 8)
  │
  └── Finished ──▶ Stop hook ──▶ Done

Permission Decision Flow

Tool call arrives
  │
  ▼
isToolEnabled() — check deny list
  │
  ▼
evaluatePermissionFlow():
  │
  ├─ Merge rule sources:
  │    • settings.json permissions.allow/deny/ask
  │    • --allowedTools CLI flag
  │    • Core tool built-in rules
  │    • SDK-provided rules
  │
  ├─ Shell commands:
  │    • Parse command via shell AST
  │    • Extract operations (read file, write file, execute, etc.)
  │    • Classify: read-only vs destructive
  │    • Match each operation against rules
  │
  ├─ Auto-mode evaluation:
  │    • Check if in auto-edit/yolo mode
  │    • Apply auto-approve decision
  │
  └─ Result: allow | deny | ask | default
       (most restrictive across all matching rules)

Hook Lifecycle

SessionStart ──▶ fires once when session initializes
UserPromptSubmit ──▶ fires before each user message is processed
PreToolUse ──▶ fires before each tool executes (can block/modify)
PostToolUse ──▶ fires after each tool executes (can inject context)
PostToolUseFailure ──▶ fires when tool execution fails
PostToolBatch ──▶ fires after all tools in a batch complete
Notification ──▶ fires for system notifications
Stop ──▶ fires when model finishes responding (can request more context)
SubagentStop ──▶ fires when subagent finishes
PreCompact ──▶ fires before context compression
PostCompact ──▶ fires after context compression
SessionEnd ──▶ fires when session terminates

Each hook can:

  • Continue — pass through, optionally with modified input or additional context
  • Block — stop execution with a reason
  • Ask — require user confirmation (PreToolUse only)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment