Repository navigation
Gotchas
Non-obvious invariants and footguns. If something behaves strangely, check here first. These are the things that have already cost time.
-
Embedding spaces are discriminated by vector dimension, not model tag.
recall/findDuplicatesskip entries whose vector length differs from the query. Themodelcolumn is informational only. SwitchingTHATCH_MODELmakes old memories invisible to search (not corrupted, not deleted — just skipped) until re-saved. There is no automatic re-embedding. -
Embedding serialization honors
byteOffset/byteLength. transformers.js can return aFloat32Arraythat is a view into a larger tensor buffer. Serializing the whole backing buffer corrupts vectors. Always serialize the view's own bytes, not the underlying buffer. -
BGE asymmetric search: queries get the prefix
"Represent this sentence for searching relevant passages: "; passages get no prefix.queryEmbedvspassageEmbed— don't swap them.
-
searchscores;recallscores and stamps telemetry.searchrecords no usage. The prompt-aware recall nudge deliberately usesdb.search()(notrecall) so nudges don't inflate the "used recently" signal. Only explicitthatch_memory_recall/ CLIsearchstamprecall_count/last_recalled_at. -
findSimilarexcludes the slug being written (self-exclusion on overwrite), so overwriting a memory doesn't warn against itself.
-
The write-time similarity warning never blocks the save.
rememberalways proceeds; the warning lists >= 0.85-similar entries and asks the agent to reconcile (merge ordedup_mark_checked). Don't add blocking logic here. -
overwrite: trueclears stale dedup verdicts for that slug. Forgetting an entry clears all verdicts involving it. Both make a pair eligible for re-reporting byfind_duplicates. This is intended. -
Archived memories are excluded by default.
search,findDuplicates, andstaleEntryCountall filterWHERE archived = 0. To search archived memories, passincludeArchived: truetothatch_memory_recall. To archive a memory, write it witharchived: true; to unarchive,archived: false. -
Updating an archived memory requires explicit
archivedparam. If the entry is already archived and aremembercall omits thearchivedparam, the tool returns an error (db.ts:585). Passarchived: trueto keep it archived orarchived: falseto unarchive. This guard prevents accidental unarchival via an unrelated content update.
-
tool.execute.afteris a plugin hook, NOT a bus event. It must stay on the hook object returned byserver. Moving it into theeventhandler (wheresession.createdlives) silently never fires it — the event bus has no such event. This was dead for weeks because the failure was invisible. -
client.tui.executeCommandonly accepts legacy alias names (session_compact,agent_cycle, ...). Unknown commands publish{command: undefined}and silently no-op — no error surfaces. For TUI commands without an alias (e.g.app.exit), publishtui.command.executedirectly viaclient.tui.publish. -
Command markdown written by the plugin is invisible until the next server
start. opencode loads config (including command discovery) before
plugins, so
installOpencodeCommandsself-heals on every load but a first-ever install only lands after a restart. -
tool.execute.afterexcludesskillandtasktools, not justthatch_*. Buffering them creates a feedback loop: the nudge triggers a skill load, which gets buffered, which triggers another nudge on the next turn. -
Hook failures are logged with a
[thatch]prefix and never swallowed. Two hooks were dead for weeks before failures were made visible. If you add a hook, log on failure. -
chat.messagehas two priority tiers: extraction nudge first (returns early), then the prompt-aware recall nudge. Don't run both in one turn. -
The extraction nudge peeks, never flushes. The buffer is NOT drained
on nudge delivery (
extraction.peek()intriggerExtraction, src/runtime.ts). It persists until the agent writes a memory or callsthatch_extraction_done. Ignored nudges accumulate; themissedNudgescounter escalates the tone (polite at 0-1 misses, insistent at 2, ALL-CAPS at 3+). The counter resets when the buffer drains. The primary opencode path doesn't use the nudge at all:triggerExtractioncreates a child session directly, and theextractingset suppresses the nudge inchat.messagewhile a direct-extraction child is active. The peek-not-drain semantics still hold for the fallback nudge path (MCP hosts and anytriggerExtractionfailure). -
A child sub-agent's
thatch_memory_rememberdrains the parent's buffer via thechildToParentMap in src/runtime.ts (declaration plus.get()lookups in the idle and remember handlers). Two paths reach this machinery: (a) the plugin-initiated child session —triggerExtractioncallsclient.session.createwith aparentID, the primary opencode path; (b) the agent-initiated background task — the model dispatches the fact-extractor via thetasktool after receiving the nudge, the fallback path still used by MCP hosts. Both use the samechildToParent/parentSnapshots/consumeSnapshotplumbing. Without this, either path would write memories in the child but never clear the parent's queue — the nudge would replay every turn.thatch_extraction_doneis the belt-and-suspenders explicit acknowledgment: it drains the buffer without requiring a memory write (covers cases where the sub-agent errors out or the host doesn't expose parent-child session relationships). -
The no-save drain runs in the child-idle handler regardless of writes.
When the extraction child goes idle after a no-save run (nothing worth
extracting), the parent's snapshot entries must still be drained from the
buffer. Without this, entries linger in pending and the nudge fires as a
synthetic (TUI-hidden) part on the next
chat.message. The child-idle handler drains the snapshot only if it still exists (the child wrote no memories). If the child did write memories,tool.execute.afteralready consumed the snapshot — the idle handler skips the drain so interleaved-turn entries survive. -
client.tui.showToastis best-effort. The toast call is wrapped in a catch-and-ignore — if the TUI is not connected (headless mode), it silently does nothing. The toast is TUI-rendered (in-app), not an OS notification.
-
PostToolBatch/postToolUsemust be silent (no stdout). The agent loop must not block on a payload that should be invisible until the next prompt. Onlyflush-toolsprints. -
The recall nudge arrives at the start of the next turn in Claude
Code/Cursor, not the end of the current one like opencode's
chat.message. A file-backed queue bridges calls that have no shared state. -
Cursor uses
conversation_idwhere Claude Code usessession_id.buffer-toolnormalizes the former to a safe filename and tries multiple field names for the tool response. -
--jsonflips the output shape.reminder --jsonandflush-tools --jsonemit{ additional_context: "..." }for Cursor; plain stdout for Claude Code. The flag is baked into the installed hook command.
-
Socket path = SHA-256 of the DB path, under
os.tmpdir(). The MCP server and hook processes compute it independently — no out-of-band coordination. ChangingTHATCH_DB_PATHmoves the socket; a stale socket from a crash is cleaned up on connection error. -
Sideband failure never blocks. Server down, stale socket, or a >2 s
timeout all return
null, andflush-toolsfalls back to the static write nudge. Never hard-fail the agent over a recall nudge.
-
Skills follow the setup scope. Project-local installs write skills to
the repo's
.claude/skills//.cursor/skills/(they version with the project);--globalwrites to$CLAUDE_CONFIG_DIR/skills//~/.cursor/skills/. A run never touches the opposite scope: if thatch skills already exist there (e.g. user-scope copies from an older local setup), setup reports them in a note but leaves them alone, so they can drift stale until refreshed or deleted. -
appendBlockleaves content alone if the markers don't parse. If the start marker is found but the end marker isn't, the whole block is skipped rather than half-replaced. Fix the markers or delete the block manually. -
The binary path is baked into installed hook commands.
thatch setupresolves<bin>from PATH (or the script's absolute path) and writes it into every hook command, so hooks survive after the setup session ends. -
Tool-arg optionality cannot be per-host at the schema level. The zod
arg shape in
TOOL_DEFSis shared by the opencode plugin and the MCP server, so making an arg optional (e.g.get_extraction_payload'ssession_id) makes it optional everywhere. Per-host enforcement has to happen in the tool'sexecute: fall back toHostToolContext.sessionIDon the opencode path, return a "pass the parent's session_id" error on MCP hosts, which have no session context. See features/commands.md.
-
DB tests use real SQLite files in
mkdtempSync, not:memory:. WAL behavior differs in-memory and would mask bugs. Temp dirs are removed inafterEach. -
BgeEmbeddingModel's realPipelineFactoryis untested — downloading the model violates the no-network rule. Lazy-load and retry logic are tested with an injected mock factory. The real model is exercised only by real use. -
bun testdoes not typecheck, andtsconfig.jsonexcludestests. Test type errors are editor-only noise unless you runtscon the test files directly. Keep test files type-clean anyway so the editor stays quiet.
-
Tests touching the config must set
THATCH_DB_PATH. The config tools derive the config file path from the environment (THATCH_DB_PATH, then the XDG default), not from the test's tempdirThatchDB. A config test that skips this reads and writes the developer's real~/.config/thatch/config.json. Seetests/notify.test.tsfor the save-and-restore pattern. -
osascript exits 0 even when macOS drops the banner. Focus/Do Not Disturb
or missing notification permission silently swallow it.
notify_userresult text therefore reports command success only; never interpret exit 0 as delivery. -
New QA use cases must be imported into the directory's barrel file
(
tests/qa/auto/index.test.tsor the live one). The.tsfiles are not discovered by bun on their own. UC-095 sat out of the suite for a release cycle and rotted (asserted 3 watch tools when 4 existed) because nobody noticed it never ran.
The thatch instructions block in CLAUDE.md / AGENTS.md is delimited by
<!-- thatch:begin --> / <!-- thatch:end --> (src/setup.ts). Before
September 2026 the delimiters were prose sentences from the instructions
themselves - and agents edit those files. An editing agent normalized
punctuation (hyphens to em dashes) in both of Jeff's instruction files, the
prose end marker stopped matching, and setup could never update the block
again while checkSetup reported markers-broken on every session. Rule:
never use file content as its own delimiter; agents will reword it. The
legacy-prose detection constants exist only to migrate old installs.
opencode v1's plugin loader (readV1Plugin, identical across 1.18.x) reads
ONLY mod.default. A module that exports a named server plus a v2-shaped
default { id, setup } (no server inside the default) makes v1 throw on
load - and the host swallows the error and SKIPS the plugin with no visible
message. The failure looks like "thatch tools disappeared", not like a load
error. This is why the dual entry exports a MERGED default object
({ id, setup, server }) and why every shim (the user's
~/.config/opencode/plugins/thatch.ts and the QA runner's generated one)
must re-export the default as well as the name. A named-only shim loads on
v1 and silently disables thatch on v2 - same invisible failure, opposite
host.
The non-bufferable filter in onToolExecuteAfter (src/runtime.ts) matches
input.tool, but a Code Mode execute call that wraps
tools.thatch_extraction_done / tools.thatch_memory_remember inside its
code string arrives as tool name execute. Unfixed, every execute-wrapped
ack landed back in the buffer as an extractable interaction: each
extraction run's payload contained only the previous run's dispatch and ack,
the nudge always found pending interactions, and a session could loop
forever on dispatch → ack → nudge (observed live in an oink session,
September 2026 - the looped agent answered every turn with "Extractor
dispatched and acknowledged. Stopping per the nudge" and never resumed the
user's work; only a human message broke the cycle). The fix unwraps execute
calls (unwrapExecuteThatchCalls in src/extraction.ts) and runs the
wrapped tool's hook semantics instead of buffering. Lesson for sibling
hooks: when a filter reasons about tool identity, an aggregator tool
(arbitrary code execution over other tools) defeats name-based matching -
unwrap the aggregation before classifying.
A September 2026 audit of every thatch tool-call error in the opencode session database (~19k calls, both v1 and v2 storage) found the failures cluster on the model's FIRST thatch call - usually the startup recall round - and fall into a short list of avoidable mistakes:
-
Bare positional string instead of an object argument (the largest
bucket):
thatch_memory_recall({ query: "..." })called asthatch_memory_recall("..."). Root cause: the opencode system prompt's startup section and the recall nudge both used positional-style examples (thatch_memory_recall "query"), and Code Mode models mimic that shape insideexecute. Both now teach the object form. -
Required discriminator params omitted:
memory_rememberwithoutlabel(models passtitle/id/statementinstead, or nothing),prediction_updatewithoutsignal,behavior_codifywithoutsituation. Fixes:labelis optional and derived from content; tool descriptions state the all-args-required contract. -
Nudges leak into tool-less agent contexts: task-dispatched sub-agents
(explore/general and friends) have restricted tool lists that exclude the
thatch tools, but
chat.messagenudges fired for them anyway, producing guaranteed "No tool named ..." error rounds. The chat.message hook now skips nudges for child sessions that are not extraction children.
The general lesson: any surface that names a tool to an LLM (system prompt,
nudge, skill text) is a usage-shape prior. Give the argument shape at the
mention site, because the model's first call happens before it has seen a
working example. Also note Code Mode execute validation failures are only
persisted in the v2 session_message store - they are invisible in the v1
part table, so error-rate analysis on old data undercounts them.
ThatchDB lazy reopen: new accessors must go through the private getters, and journal writes stay guarded at call sites
ThatchDB reopens its SQLite connection on first use after close()
(src/db.ts, the #db/#predictions/#behaviors/#chat private getters), so
an in-flight tool call that races a v2 plugin reload's dispose() completes
instead of throwing "Cannot use a closed database". Two rules keep this intact:
-
New facade methods need no extra wiring, but new state-holding members
do. The engine facades (
ChatStore,PredictionEngine,BehaviorEngine) capture theDatabasehandle at construction; the reopen rebuilds them. A new member that captures the Database directly must get its own private getter that ensures the handle is open first - a plain field keeps serving the stale closed handle and the reopen silently never fires for it (this exact bug is pinned by the "engine facades are rebuilt" test in tests/db.test.ts). -
Post-dispose journal writes are no-ops at their call sites, not in
db.ts. After a reload, the RELOADED instance owns the
runtime_staterows, so delayed writers insrc/runtime.tscheck thedisposedflag BEFORE touching the db - the lazy reopen would otherwise hand a stale writer a fresh connection. All three journal callbacks are guarded (the extraction pipeline's finalization, the WatcherRegistry journal - a poll cycle suspended at an await can outlivewatchers.dispose()- and the wrap-up arming write, whose command executes are not drained by the v2 cleanup). Do not "fix" the guard away by moving it into db.ts: the accessor cannot tell a legitimate in-flight caller from a stale writer.
-
A plain-scalar frontmatter value containing a colon followed by a space
is invalid YAML, and not every host path rescues it.
/thatch/hygiene's description ("Tend the memory store: stale entries, ...") shipped unquoted; the first parse throws and only opencode's fallback sanitizer recovers it — and a long-lived server was observed dropping the description entirely while a fresh server parsed it fine, which made the bug look like a TUI rendering issue. The renderer quotes every description (yamlQuotein src/commands.ts) and a unit test pins the quoted form. Keep it that way for any new frontmatter field whose value is free-form prose.
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