Repository navigation
Feature: Nudge Pipeline
Unless noted, client.* names below are the opencode v1 mapping; the v2 equivalents and degrades are in opencode-plugin.md.
Runs on every user message. Priority tiers share one embedding computation. The pipeline injects synthetic parts---text the model sees in the conversation but the user does not see in the TUI. This is the inverse of toast notifications, which are TUI-visible but model-invisible.
The nudge pipeline is thatch's core innovation. It is not a passive store-and-retrieve system. It is a nudge layer that changes agent behavior by injecting context at the right moment. See extraction.md for the extraction pipeline (plugin-driven on opencode; nudged on MCP hosts only), prediction-engine.md for the prediction auto-fire, and behavior-engine.md for the behavior auto-fire.
On every user message, thatch runs through the priority tiers:
- Extraction nudge (MCP hosts only)---if tool interactions are queued, tell the agent to dispatch an extractor. There is deliberately no opencode extraction nudge: extraction is plugin-driven at session idle.
- Recall nudge---semantically search stored memories for context relevant to the user's prompt.
- Prediction nudge---surface user decision-model predictions relevant to the current situation.
- Behavior nudge---surface LLM self-discipline rules relevant to the current situation.
Tiers 2--4 fire independently and share one embedding computation. Tier 1 (extraction) exists only on the MCP path.
Two host paths deliver the tiers through different mechanisms. The opencode path runs in-process with direct event hooks and has THREE tiers (recall, prediction, behavior - extraction is plugin-driven at session idle, not nudged). The MCP path runs via external CLI hook processes that communicate with the long-lived MCP server through the sideband socket and keeps all four.
If the session is in the compacting set, thatch checks whether the incoming
message is a compaction summary. If it is, all nudges are suppressed---tools
are blocked during summary generation, so a nudge that says "call
thatch_memory_recall" would trigger a blocked-tool error. If the message is
not a compaction summary, compaction has already failed (the auto-continue
never fired). Thatch clears the stale compacting flag and proceeds normally.
See compaction-recovery.md.
If the session is a child in childToParent but not in extractionChildren
(a task-dispatched sub-agent), all nudge tiers are skipped and the hook
returns early. Dispatched sub-agents may have restricted tool lists
that exclude the thatch tools, and even where the tools exist the child is
driven by its dispatch prompt, so a nudge only produces "No tool named"
error rounds or noise. See
session-lifecycle.md for the extractionChildren
distinction.
There is deliberately no extraction nudge on the opencode path. The old
model-driven handshake (nudge → model dispatches a sub-agent → parent acks
with thatch_extraction_done → child fetches by a model-copied session ID)
raced its own state machine: the parent's ack accepted the buffer before
the child fetched, a no-claim completion wiped the accepted set, and
mis-targeted IDs made every transition a silent no-op - so the same queued
count re-fired forever while buffered interactions were silently lost (the
September 2026 dispatch-loop report, both defects). Extraction on opencode
is now plugin-driven end-to-end (triggerExtraction at session idle); see
extraction.md. The MCP path keeps its nudge - see below.
- Extracts user prompt text from non-synthetic text parts. Skips if the text
is shorter than
MIN_PROMPT_LEN(10 characters, hardcoded). Trivially short prompts like "yes" or "ok" match too broadly to be useful. - Embeds the prompt with the warm in-process model:
model.queryEmbed(text). - Searches memories via
db.search([repo, "global"], embedding, { limit: 5 }). This usesdb.search, notdb.recall, to avoid inflating recall telemetry. The agent has not actually read the memories yet---the nudge only checks whether they relate to the prompt. - Filters results to
_score >= RECALL_THRESHOLD(0.55, env:THATCH_RECALL_THRESHOLD). - If matches survive: injects
recallNudge(matches)as a synthetic text part and fires a toast notification viaclient.tui.showToast. - Wrapped in its own try/catch. A recall failure does not block prediction or behavior tiers.
- Reuses the same embedding from tier 2. No extra model call.
- Scores prediction matchers via
db.scorePredictionNudge([repo, "global"], embedding, PREDICTION_THRESHOLD). This is a mechanical query: cosine-match against matchers, then score linked predictions by Bayesian confidence. - Threshold: 0.60 (env:
THATCH_PREDICTION_THRESHOLD). Higher than recall because surfacing a preference nudge is more disruptive---the agent may act on it or surface it to the user. - If matches: injects
predictionNudge(items)as a synthetic text part and fires a toast. - Independent try/catch.
See prediction-engine.md for the confidence model, matcher/prediction/edge data model, and the ambiguity-surfacing behavior.
- Reuses the same embedding. One more cosine scan, this time against
behavior_matchers. - Scores via
db.scoreBehaviorNudge([repo, "global"], embedding, BEHAVIOR_THRESHOLD). - Threshold: 0.60 (env:
THATCH_BEHAVIOR_THRESHOLD). - If matches: injects
behaviorNudge(items)as a synthetic text part and fires a toast. - Independent try/catch.
See behavior-engine.md for the ham/spam feedback loop and the self-discipline rule data model.
Tiers 2, 3, and 4 all fire independently. Any subset may inject. All share one embedding computation, reused across three cosine scans against different tables.
The flush-tools subcommand fires on UserPromptSubmit (Claude Code) or
beforeSubmitPrompt (Cursor). It reads JSON from stdin:
{
"session_id": "...",
"conversation_id": "...",
"prompt": "...",
"message": "...",
"text": "...",
"user_input": "...",
"input": "..."
}Session ID is session_id ?? conversation_id. Prompt text is the first
present field from prompt, message, text, user_input, input.
Three priority tiers, first match wins:
-
peekQueue(sessionID)peeks the file-backed JSONL queue without draining it. The queue persists until the extractor completes (extraction_donewith the parent's session id) or the agent callsmemory_remember, which triggersconsumeQueue. The parent's dispatch-time ack resets the escalation counter but does NOT drain - a drain at ack time deleted the queue before the sub-agent fetched (the accept-before-fetch loss). - If interactions are pending: increments the missed count, prints
extractionNudge(count, missed, sessionID). - Breaks. Tiers 2--3 are skipped.
See extraction.md for the file-backed queue and the
buffer-batch subcommand that populates it.
- Extracts prompt text. Skips if shorter than 10 characters.
- Connects to the sideband socket and fetches all three in parallel:
sidebandMatch,sidebandPredictions,sidebandBehaviors. - Same thresholds as opencode: 0.55 recall, 0.60 prediction, 0.60 behavior.
- Concatenates any non-empty results:
claudeRecallNudge,predictionNudge,behaviorNudge. - Prints to stdout. Breaks.
The sideband socket lets short-lived hook processes use the warm embedding model in the long-lived MCP server, avoiding a cold ~34 MB model load on every prompt. See sideband.md for the socket protocol.
Fallback for conversational turns with no tool use and no memory matches.
Prints claudeWriteNudge():
After responding, check: did you learn new project knowledge, user preferences, or corrections worth persisting? If so, save to thatch.
On any error, falls back to the static write nudge. If that import also fails, silent failure---the hook never blocks the user's prompt over an unrelated error.
Standalone prediction fire---queries the sideband for scored predictions
only. Not installed as a hook. Exists for testing and for hosts that want
prediction-only output. flush-tools already fires predictions in tier 2.
- Claude Code: plain text to stdout. Claude Code feeds hook stdout into the agent's context.
-
Cursor:
--jsonflag wraps output as{ additional_context: "..." }. Cursor injectsadditional_contextinto the session.
All thresholds are env-overridable.
| Threshold | Default | Env var |
|---|---|---|
| Recall nudge | 0.55 | THATCH_RECALL_THRESHOLD |
| Prediction auto-fire | 0.60 | THATCH_PREDICTION_THRESHOLD |
| Behavior auto-fire | 0.60 | THATCH_BEHAVIOR_THRESHOLD |
| Min prompt length | 10 chars | hardcoded (MIN_PROMPT_LEN) |
The recall threshold (0.55) is lower than findDuplicates's 0.85 because
"relates to" is a weaker signal than "duplicate." The prediction and behavior
thresholds (0.60) are higher than recall because surfacing a preference or
self-discipline rule is more disruptive than surfacing a memory---the agent
may act on it or interrupt the user.
client.tui.showToast is best-effort. If the TUI is not connected (headless
mode), the call is silently ignored. Toasts are model-invisible: they go to
the user only, not into the conversation history.
| Trigger | Message | Variant | Duration |
|---|---|---|---|
| Recall matches | recalled N memories | info | 3s |
| Prediction matches | N predictions surfaced | info | 3s |
| Behavior matches | N behaviors surfaced | info | 3s |
| Extraction child goes idle | new: N, updated: M, deleted: K | success | 4s |
The extraction metrics toast fires only when memories were actually written. No-save runs produce no toast to avoid notification fatigue.
Nudges are injected via client.session.prompt with noReply: true and
synthetic: true. The synthetic flag is a TUI-visibility marker, not a
model-routing flag. The model sees the nudge text in the conversation. The
user does not see it in the TUI transcript. This asymmetry is what makes the
nudge-injection pattern work: the model can act on the nudge without
cluttering the user's view.
Do not confuse synthetic with ignored. A part marked ignored is dropped
from the LLM-bound message. A part marked synthetic is only hidden from the
TUI display. The opencode framework's history serializer filters on
!part.ignored, not !part.synthetic.
-
Extraction pipeline (extraction.md): the MCP tier 1
is the extraction nudge. On opencode there is no extraction
nudge - extraction is plugin-driven at session idle, so
chat.messageruns the recall/prediction/behavior tiers only. -
Memory store (memory-store.md): tier 2 searches
stored memories via
db.search(). This path records no telemetry---the agent has not read the memories yet. - Prediction engine (prediction-engine.md): tier 3 scores prediction matchers by cosine, then scores linked predictions by Bayesian confidence.
- Behavior engine (behavior-engine.md): tier 4 scores behavior matchers by cosine, then scores linked behaviors by ham/spam confidence.
- Sideband IPC (sideband.md): MCP hosts use the sideband socket for warm-model access. The hook process sends the prompt text; the MCP server embeds it and runs the cosine scans.
- Compaction recovery (compaction-recovery.md): the nudge pipeline is suppressed during compaction. The compaction guard clears stale flags when compaction fails.
-
Session lifecycle (session-lifecycle.md):
direct extraction (
triggerExtraction) is triggered by session idle; there is no extraction tier to suppress on opencode.
| File | Role |
|---|---|
src/runtime.ts |
opencode: per-message nudges (recall/prediction/behavior tiers; no extraction nudge), compaction guard |
bin/thatch |
MCP: flush-tools and flush-predictions subcommands (all 4 tiers) |
src/sideband.ts |
MCP: sideband client helpers (sidebandMatch, sidebandPredictions, sidebandBehaviors) |
src/prompts.ts |
All nudge formatting functions (recallNudge, claudeRecallNudge, predictionNudge, behaviorNudge, extractionNudge, claudeWriteNudge) |
- Tier 1 (extraction, MCP only) returns early. Tiers 2-4 are skipped when tier 1 fires.
- Tiers 2-4 share one embedding computation. One embed, three cosine scans against different tables.
- Each tier is wrapped in its own try/catch. A failure in one tier does not block the others.
- The recall nudge uses
db.search(), notdb.recall(), to avoid inflating telemetry. The agent has not read the memories yet. - The recall threshold (0.55) is lower than prediction/behavior (0.60) because "relates to" is weaker than "should surface to user."
- Nudges are suppressed during compaction. Tools are blocked during summary generation, so a nudge saying "call
memory_recall" would trigger a blocked-tool error. -
syntheticmeans TUI-invisible, not model-invisible. The model sees the nudge text; the user does not. Do not confuse withignored, which drops the part from the LLM-bound message. - MCP host hooks must be silent on success. Only
flush-toolsprints. Any stdout from other hooks delays the agent loop.
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: Setup
- Feature: Sideband
- Feature: Watchers