Repository navigation
Feature: Extraction
Automated capture of durable facts from tool interactions. The agent does not need to remember to save — the system buffers tool calls and nudges the agent to extract memories from them.
The facts most worth remembering overwhelmingly surface in tool call results
(file contents, command output, API responses, git state) rather than in
conversation prose. So tool calls serve as both the trigger and the
queue. A fact that exists only in conversation never enters the buffer;
the system prompt instructs the agent to call thatch_memory_remember
directly for conversation-derived knowledge.
- Buffers every non-
thatch_*, non-skill, non-tasktool call for later extraction - Two paths: direct extraction via a child session (opencode, primary) and nudge-based extraction (all hosts, fallback for opencode, primary for MCP)
- The extraction nudge escalates: polite (0–1 missed), insistent (2), all-caps shouting (3+)
-
thatch_get_extraction_payloadfetches queued interactions as JSON, keeping the full payload out of the main session's context window -
thatch_extraction_doneacknowledges and quiets the nudge - AMQP-style buffer lifecycle for opencode: pending → accepted → completed, with requeue on failure
- File-backed JSONL queue for MCP hosts (no cross-call state)
- Parent-child session drain: the child drains the parent's snapshot, preserving interleaved-turn entries added after the snapshot was taken
After every tool execution, non-thatch_*, non-skill, non-task tool
calls are buffered for later extraction.
opencode — tool.execute.after hook (v1) / tool.hook (v2) in src/runtime.ts:
- In-memory ring buffer per session (
ExtractionPipelineinsrc/extraction.ts) - Max 20 interactions per session
- For child sessions, also tracks new/updated/deleted metrics via
childMetrics -
tool.execute.afteris a plugin hook, not a bus event. Moving it into theeventhandler silently never fires it — the event bus has no such event - Filtering rationale:
thatch_*tools would echo the store back into itself;skill/taskmeta-tools would create a feedback loop (extraction triggers a skill load, which gets buffered, which triggers another extraction) - Code Mode
executecalls are unwrapped before filtering (unwrapExecuteThatchCallsinsrc/extraction.ts): when the call's code invokestools.thatch_*tools, the wrapped tools' hook semantics run (ack, drain, metrics) and the call itself is never buffered. Matching on the outer tool name alone re-queued the pipeline's own dispatch/ack traffic every cycle — each extraction run queued the next one, producing an infinite dispatch/ack loop (observed live, September 2026). Execute calls that touch no thatch tools buffer as normal.
MCP hosts — bin/thatch, src/extract-queue.ts:
- Claude Code:
PostToolBatchhook →thatch buffer-batch(reads JSON from stdin:{ session_id, tool_calls }) - Cursor:
postToolUsehook →thatch buffer-tool(reads JSON from stdin, single tool, usesconversation_id) - File-backed JSONL queue under
$XDG_CACHE_HOME/thatch/queue/<session>.jsonl(max 20, oldest dropped) - Silent on success (no stdout) so the agent loop is not delayed
- Filters the same tools as opencode (
mcp__thatch__memory_remember,mcp__thatch__extraction_done,mcp__thatch__*,skill,task,agent)
When a parent session goes idle (session.status idle event) with pending
buffer interactions:
-
triggerExtractionadds the parent ID to theextractingset — this suppresses the nudge inchat.message - Peeks the buffer to count pending interactions
- Creates a child session via
client.session.create({ parentID, title: "thatch-extraction" }) - The
session.createdevent fires, populatingchildToParentandparentSnapshots(a snapshot of the full pending buffer at dispatch time) - Adds the child ID to the
extractionChildrenset - Prompts the child with
extractionDirectPrompt(count, sessionID) - If background sub-agents are enabled
(
OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS):promptAsync. Otherwise: fire-and-forgetprompt - On prompt failure:
cleanupChildremoves the child from all maps and deletes the child session
The child session then:
- Calls
thatch_get_extraction_payloadto fetch the queued interactions - Runs the thatch-fact-extractor skill
- Writes memories via
thatch_memory_remember - Goes idle
On child idle (session.status idle event, extraction child):
- Drains the parent's snapshot from the pending buffer via
consumeSnapshot— removes only entries captured at dispatch time by reference identity, preserving interleaved-turn entries - Fires a toast with extraction metrics (only if memories were actually written)
-
completeAccepted(parentID), resetsmissedNudges - Cleans up all maps
-
consume(childID)— drains the child's own buffer - Deletes the child session
If direct extraction was never triggered or threw an error (session not in
the extracting set) and the buffer has pending interactions:
- The next user message (
chat.messagefor opencode,UserPromptSubmit/beforeSubmitPromptfor MCP) gets an extraction nudge - The nudge carries the session ID and fetch tool name — not the full
payload — so the sub-agent calls
thatch_get_extraction_payloadto retrieve the interactions as a tool response
Nudge escalation (via the missedNudges counter):
| Missed count | Tone |
|---|---|
| 0–1 | Polite |
| 2 | Insistent |
| 3+ | All-caps shouting |
The buffer persists until the agent writes a memory or calls
thatch_extraction_done. Ignored nudges repeat and escalate.
-
opencode: in-memory
missedNudgesmap -
MCP hosts: file-backed
.countfile per session
Buffered interactions move through four states in ExtractionPipeline
(src/extraction.ts):
-
pending — interactions in the ring buffer. The nudge fires on
chat.message. -
accepted — moved from pending by
accept(). The nudge quiets, but entries are not dropped yet. -
completed — dropped by
completeAccepted(). Triggered by amemory_rememberorextraction_donecall in the child, or the child going idle. -
requeued — moved back to pending by
requeueAccepted(). Triggered by the child session erroring or being deleted before completing.
Key methods:
| Method | Action |
|---|---|
push() |
Add interaction to pending buffer (capped at 20) |
peek() |
Read without clearing |
consume() |
Delete the session's pending buffer (called on memory write) |
accept() |
Move pending to accepted — quiet the nudge, hold entries |
completeAccepted() |
Drop accepted entries — extractor finished |
requeueAccepted() |
Move accepted back to pending — extractor died |
consumeSnapshot() |
Remove only entries that were in the snapshot (by reference identity). Used for child-parent drain. |
When a child session writes a memory via thatch_memory_remember:
-
tool.execute.afterdetects the memory write - If in a child session (
childToParent.has(sessionID)): track metrics,completeAccepted(parentID), drain the parent's snapshot viaconsumeSnapshot, reset the parent'smissedNudges -
consume(sessionID)— drains the child's own buffer
consumeSnapshot is snapshot-aware: it removes only entries that were
in the parent's buffer at dispatch time (by reference identity).
Interleaved-turn entries — added after the snapshot was taken, while the
child was extracting — survive. This prevents data loss when the parent
continues working while the child extracts.
-
drainExtractionQueue(sessionID)callsresetMissedCount+consumeQueue(deletes the JSONL file) - Triggered by:
thatch_extraction_donecalled with the parent'ssession_id, orthatch_memory_remembercalled -
appendBatchinextract-queue.tsself-detectsmemory_remember/extraction_donecalls and resets the counter + consumes the queue inline
- No-op confirmation (
[acknowledged]) unlesssession_idis passed anddrainExtractionQueueis wired - On the MCP path with
session_id: resets the missed-nudge count + consumes (deletes) the file-backed queue - The real state transitions happen in the host's post-tool hook
(
tool.execute.afterfor opencode,PostToolBatch/appendBatchfor MCP) - The tool exists primarily so the model has a recognizable name to key on
- Fetches the queued tool interactions for extraction as serialized JSON
(
interactions,projectStore,globalStore) -
opencode: peeks accepted + pending interactions, builds JSON via
buildExtractionPayload - MCP: peeks the file-backed queue, builds the same JSON payload
-
session_idis optional: omitted, it resolves to the invoking session viaHostToolContext.sessionID(opencode path). On MCP hosts there is no session context, so an omittedsession_idreturns a "pass the parent session's session_id" error. Explicit IDs always win - that is how a sub-agent drains the parent's queue - Returns
nullwhen no interactions are queued - Read-only — it peeks the queue; it does not consume it. Consumption is
extraction_done's job
- Experimental flag:
OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS(set inmise.toml) - When enabled:
promptAsyncfor child session creation (async extraction) - When disabled: fire-and-forget
prompt(synchronous but unawaited)
-
Memory store — extraction writes memories via
thatch_memory_remember - Nudge pipeline — the extraction nudge is tier 1 (highest priority, returns early)
-
Session lifecycle — direct extraction is triggered
by
session.statusidle; child lifecycle is managed by the event handler - Skills — the thatch-fact-extractor skill is dispatched to child sessions
- Multi-host — in-memory ring buffer for opencode, file-backed queue for MCP hosts
- Compaction recovery — extraction nudge is suppressed during compaction (tools are blocked)
| File | Responsibility |
|---|---|
src/extraction.ts |
In-memory ring buffer (ExtractionPipeline), shared payload builders (buildExtractionPayload, deriveTitle, summarizeArgs) |
src/extract-queue.ts |
File-backed JSONL queue for MCP hosts |
src/runtime.ts |
opencode hooks: tool.execute.after, session.status idle, session.created, session.error, session.deleted, chat.message (nudge tier 1) -- shared by the v1/v2 adapters |
bin/thatch |
buffer-batch, buffer-tool, flush-tools subcommands |
src/prompts.ts |
extractionNudge (with escalation), extractionDirectPrompt
|
-
Tool filtering is absolute.
thatch_*,skill, andtasktools are never buffered. Buffering them would echo the store into itself or create a feedback loop. -
The nudge peeks, never flushes. The buffer persists until a memory
write or
extraction_done. Ignored nudges repeat and escalate. -
consumeSnapshotis snapshot-aware. It removes only entries captured at dispatch time (by reference identity), preserving interleaved-turn entries added while the child was extracting. -
tool.execute.afteris a plugin hook, not a bus event. The event bus has no such event. Moving the buffering logic into theeventhandler silently never fires it. -
MCP host hooks must be silent.
PostToolBatch/postToolUseproduce no stdout — onlyflush-toolsprints. Any stdout delays the agent loop. - The no-save drain runs unconditionally. The child-idle handler drains the remaining snapshot regardless of whether the child wrote memories, covering no-save extraction runs.
User
- Guide: Behavior Engine
- Guide: Cli
- Guide: Code Review
- Guide: Commands
- Guide: Cross Session Chat
- Guide: Deduplication
- Guide: Default Behaviors
- Guide: Extraction
- Guide: Hygiene
- Guide: Memory
- Guide: Notifications
- Guide: Prediction Engine
- Guide: Overview
- Guide: Setup
- Guide: Skills
- Guide: Watchers
Developer
Dev Feature Guides
- Feature: Behavior Engine
- Feature: Cicd
- Feature: Cli
- Feature: Commands
- Feature: Compaction Recovery
- Feature: Cross Session Chat
- Feature: Database
- Feature: Deduplication
- Feature: Extraction
- Feature: Hygiene
- Feature: Memory Store
- Feature: Multi Host
- Feature: Notifications
- Feature: Nudge Pipeline
- Feature: Opencode Plugin
- Feature: Prediction Engine
- Feature: Qa System
- Feature: Overview
- Feature: Repo Identity
- Feature: Session Lifecycle
- Feature: Session Tabs
- Feature: Setup
- Feature: Sideband
- Feature: Watchers