@jeffober/thatch 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@ The extraction nudge or dispatch prompt will give you a session ID. Call `get_ex
18
18
  4. Apply the durability test before saving: will this still be true and useful in a future session that knows nothing about this branch, commit, or session? If not, skip it. Do not store point-in-time values (migration indices, line numbers, file counts), commit hashes or branch history narratives (git is the source of truth), or anything re-derivable from the codebase faster than recalling it.
19
19
  5. Write for a future session with zero current context. No "we", "our session", "just now".
20
20
  6. Call `extraction_done` (with `session_id` set to the parent session's ID from the nudge) to mark the run complete. Do this even when nothing was worth saving: the parent session holds the buffered interactions until a completion signal arrives, and a memory write is not one on a no-save run.
21
- 7. Your final message must be exactly: "Extraction complete." — do not list or summarize what you saved. The memories are in the store; the parent session does not need a report.
21
+ 7. Your final message must be exactly: "Extraction complete. This is the extractor's final report - a background-task completion, not an extraction nudge; the buffer is drained. Never re-dispatch an extractor because a completion arrived - a new extraction nudge is the only dispatch signal." - do not list or summarize what you saved. The memories are in the store; the parent session does not need a report. The frame rides with the completion on purpose: a bare "Extraction complete." was indistinguishable from nudge traffic mid-loop, and the parent model misread it as new work (the September 2026 re-dispatch loop).
22
22
 
23
23
  ## What to extract
24
24
 
@@ -20,7 +20,7 @@ This skill is the catch.
20
20
 
21
21
  Call `thatch_memory_show` with the label (or accept the memory content directly if dispatched by a coordinator that already read it).
22
22
 
23
- Note the `created_at` and `updated_at` dates. A memory that was last updated recently is less likely to be stale than one that has not been touched in months.
23
+ Note the age line ("This memory was last updated N ago"). A memory that was last updated recently is less likely to be stale than one that has not been touched in months.
24
24
 
25
25
  ## Step 2: Classify Claims
26
26
 
@@ -111,6 +111,6 @@ If no corrections were needed, report "all claims verified, no updates needed."
111
111
 
112
112
  # Proactive Use
113
113
 
114
- When `thatch_memory_show` or `thatch_memory_recall` returns a memory with an old `updated_at` date (more than a few weeks), consider loading this skill to fact-check it before relying on its code-related claims. The `updated_at` date is included in the memory output for this purpose.
114
+ When `thatch_memory_show` or `thatch_memory_recall` returns a memory whose age line shows it has not been updated in a while (more than a few weeks), consider loading this skill to fact-check it before relying on its code-related claims. The age prose ("This memory was last updated N ago") is included in the memory output for this purpose.
115
115
 
116
116
  This is not mandatory for every old memory. Use judgment: a memory about a stable architectural decision may be fine for months, while a memory about file locations or function names is more likely to drift.
@@ -127,7 +127,7 @@ methods. The v1 hook shapes:
127
127
  | `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the TUI action (`session_compact` via `executeCommand`, `app.exit` via `publish`; the exit path unregisters the session from the chat directory first). See [features/session-lifecycle.md](features/session-lifecycle.md). |
128
128
  | `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
129
129
  | `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. For `thatch_chat_*` tool calls, builds the transcript echo (`chatEchoText`) and posts it as a visible non-synthetic `noReply` part. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
130
- | `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
130
+ | `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation), and for task-dispatched sub-agent sessions (`childToParent` without `extractionChildren`) - their tool lists may exclude the thatch tools and their work is driven by the dispatch prompt. |
131
131
  | `event` | Subscribes to all session bus events. `session.created`: records `childToParent` + `parentSnapshots` (shallow copy of the parent's buffer for snapshot-aware drain), then sends the session-start reminder via `client.session.prompt` carrying the hygiene heartbeat (pending dedup pairs, stale count, orphaned branch memories) when any signal is non-zero. `session.status`: records the session's live status for the watcher and chat delivery gates; on idle, if the session is a child, drains its snapshot, fires a toast with `childMetrics`, and deletes the child session; if the session is a parent with pending tool interactions and not already extracting, calls `triggerExtraction` to create a direct-extraction child, then flushes pending watcher events and pending chat mail (the session just became deliverable). `session.error`: requeues the parent's buffer (child died without draining). `session.deleted`: cleans up `childToParent`, `parentSnapshots`, `childMetrics`, and `extracting`, cancels the session's watchers, and unregisters it from the chat directory (the crash path — a dead process fires no event — is covered by heartbeat staleness). `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
132
132
  | `dispose` | Stops the version checker, the watcher poller, and the chat poller, closes the DB. |
133
133
 
@@ -179,11 +179,15 @@ Two of these hooks were dead for weeks because failures were invisible.
179
179
  10. **Background completion narration is suppressed, not prevented.** When a
180
180
  background sub-agent completes, opencode injects a `<task_result>` block
181
181
  into the parent session and triggers a full model generation. Thatch
182
- cannot cancel this turn (no pre-response hook). Two mitigations: the
183
- fact-extractor skill's return value is constrained to the literal
184
- "Extraction complete." so the injected block has no narratable content,
185
- and the system prompt's "Background Task Completions" section instructs
186
- the model not to narrate completions or treat them as approval to act.
182
+ cannot cancel this turn (no pre-response hook). The mitigations: the
183
+ fact-extractor skill's return value is a fixed string so the injected
184
+ block has nothing to narrate AND the string self-identifies as a
185
+ completion, not an extraction nudge (a bare "Extraction complete." was
186
+ misread mid-loop as new nudge traffic and re-dispatched - the September
187
+ 2026 re-dispatch loop); the system prompt's "Background Task
188
+ Completions" section instructs the model not to narrate completions or
189
+ treat them as approval to act, and that a completion is never a
190
+ re-dispatch signal.
187
191
  11. **Prediction engine is a statistical model, not an LLM call.** The query
188
192
  (embed prompt, cosine-match against matchers, score linked predictions)
189
193
  is mechanical — same shape as `thatch_memory_recall` but against different
@@ -209,7 +213,7 @@ Two of these hooks were dead for weeks because failures were invisible.
209
213
  ## Data flow
210
214
 
211
215
  ```text
212
- thatch_memory_remember(label, content)
216
+ thatch_memory_remember(label?, content)
213
217
  → model.passageEmbed("# label\n\ncontent") → Float32Array
214
218
  → db.findSimilar(store, embedding) — write-time collision check (no telemetry)
215
219
  → db.remember(store, label, content, embedding, model.name, opts)
@@ -191,7 +191,14 @@ rather than one so a single late poll cycle does not flap the roster.
191
191
  Heartbeat age is the ONLY liveness signal, by design. A host process id
192
192
  cannot serve: the same session is re-hosted by a new process on every
193
193
  `-s` resume, so a pid stamped on the row lies as soon as a different
194
- harness beats it (it says dead while the heartbeat proves alive). `isStale`
194
+ harness beats it (it says dead while the heartbeat proves alive). RESTART
195
+ grace: a new daemon re-hosts the dead one's hosted set under a bounded
196
+ window (`CHAT_REHOST_GRACE_MS`, 24h, `THATCH_CHAT_REHOST_GRACE_MINUTES`
197
+ to override) - open TUI tabs reconnect but emit no events until typed
198
+ into, so without the grace they would read stale while alive-idle and
199
+ mail to them would not wake the tab. Sessions that emit activity
200
+ graduate to permanent hosting; still-silent ones (closed tabs) age out
201
+ at the grace and go stale. `isStale`
195
202
  (src/chat.ts) is the single rule; `chatLiveness`, the `chat_send` note,
196
203
  and the broadcast skip all call it, so they cannot disagree. `chat_list`
197
204
  groups the result into Active and Stale sections - the stale section's
@@ -58,7 +58,7 @@ The "global" store always exists — it is inserted at schema init (`INSERT OR I
58
58
  ### Write path (memory_remember)
59
59
 
60
60
  ```text
61
- agent calls memory_remember(label, content, opts)
61
+ agent calls memory_remember(label?, content, opts) - label is derived from the content's first heading or line when omitted
62
62
  → model.passageEmbed("# label\n\ncontent") → Float32Array
63
63
  → db.findSimilar(store, embedding, { excludeSlug }) → write-time collision check
64
64
  → db.remember(store, label, content, embedding, model.name, opts)
@@ -49,6 +49,18 @@ not a compaction summary, compaction has already failed (the auto-continue
49
49
  never fired). Thatch clears the stale `compacting` flag and proceeds normally.
50
50
  See [compaction-recovery.md](compaction-recovery.md).
51
51
 
52
+ #### Task sub-agent guard
53
+
54
+ If the session is a child in `childToParent` but not in `extractionChildren`
55
+ (a task-dispatched sub-agent), all nudge tiers are skipped and the hook
56
+ returns before tier 1. Dispatched sub-agents may have restricted tool lists
57
+ that exclude the thatch tools, and even where the tools exist the child is
58
+ driven by its dispatch prompt, so a nudge only produces "No tool named"
59
+ error rounds or noise. `extraction.requeueStaleAccepted()` still runs before
60
+ the guard - it is session-independent maintenance. See
61
+ [session-lifecycle.md](session-lifecycle.md) for the `extractionChildren`
62
+ distinction.
63
+
52
64
  #### Tier 1---Extraction nudge (fallback path)
53
65
 
54
66
  Fires only when direct extraction was never triggered or threw (the session is
@@ -75,7 +75,7 @@ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
75
75
  | TUI app exit | `client.tui.publish` | none | degrade (the wrap-up exit's checklist and flush run; the exit does not - upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984). On v2 the right target closes THIS session's tab (tab-scoped), not `app.exit`: the daemon hosts every tab; the `tui` companion entrypoint's `ui.tabs.close` is the known lift candidate) |
76
76
  | tab close | n/a | no session.deleted on v2 tab close | known gap: a closed tab's watchers keep polling until their TTL (deliveries fail their idle gate and stay pending); restart dormancy covers the daemon-restart case, not tab close |
77
77
  | wake gate after reload | `session.status` event map | map rehydrates empty | known gap: v2's fetchStatuses stub is structural (the promise context has no session.status), so in the reload window - until the session's next status event - the proactive-prompt gate is fully open |
78
- | dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
78
+ | dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; child rows carry a kind marker (extraction vs task-dispatched sub-agent, defaulting to extraction for pre-marker rows) so only extraction children restore the `extracting`/`extractionChildren` sets; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
79
79
 
80
80
  ## Live verification and remaining unknowns
81
81
 
@@ -117,7 +117,7 @@ first-ever install is invisible until the next server start.
117
117
  ## Interactions with other features
118
118
 
119
119
  - Extraction pipeline ([extraction.md](extraction.md)): direct extraction is triggered by `session.status idle`; child lifecycle managed here
120
- - Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `extracting` set suppresses tier 1; `compacting` set suppresses all tiers
120
+ - Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `extracting` set suppresses tier 1; `compacting` set suppresses all tiers; task-dispatched sub-agents (in `childToParent`, not in `extractionChildren`) suppress all tiers
121
121
  - Hygiene ([hygiene.md](hygiene.md)): hygiene report runs at `session.created` for top-level sessions
122
122
  - Compaction recovery ([compaction-recovery.md](compaction-recovery.md)): `session.compacted` event clears the compacting flag
123
123
  - Memory store ([memory-store.md](memory-store.md)): child sessions write memories via `memory_remember`, which triggers drain via `tool.execute.after`
@@ -41,7 +41,7 @@ here first. These are the things that have already cost time.
41
41
  a memory, write it with `archived: true`; to unarchive, `archived: false`.
42
42
  - **Updating an archived memory requires explicit `archived` param.** If the
43
43
  entry is already archived and a `remember` call omits the `archived` param,
44
- the tool returns an error (`db.ts:395`). Pass `archived: true` to keep it
44
+ the tool returns an error (`db.ts:585`). Pass `archived: true` to keep it
45
45
  archived or `archived: false` to unarchive. This guard prevents accidental
46
46
  unarchival via an unrelated content update.
47
47
 
@@ -229,3 +229,60 @@ wrapped tool's hook semantics instead of buffering. Lesson for sibling
229
229
  hooks: when a filter reasons about tool identity, an aggregator tool
230
230
  (arbitrary code execution over other tools) defeats name-based matching -
231
231
  unwrap the aggregation before classifying.
232
+
233
+ ## LLMs guess tool-call shapes on the first call, so teach the shape at the mention site
234
+
235
+ A September 2026 audit of every thatch tool-call error in the opencode
236
+ session database (~19k calls, both v1 and v2 storage) found the failures
237
+ cluster on the model's FIRST thatch call - usually the startup recall round -
238
+ and fall into a short list of avoidable mistakes:
239
+
240
+ - **Bare positional string instead of an object argument** (the largest
241
+ bucket): `thatch_memory_recall({ query: "..." })` called as
242
+ `thatch_memory_recall("...")`. Root cause: the opencode system prompt's
243
+ startup section and the recall nudge both used positional-style examples
244
+ (`thatch_memory_recall "query"`), and Code Mode models mimic that shape
245
+ inside `execute`. Both now teach the object form.
246
+ - **Required discriminator params omitted**: `memory_remember` without
247
+ `label` (models pass `title`/`id`/`statement` instead, or nothing),
248
+ `prediction_update` without `signal`, `behavior_codify` without
249
+ `situation`. Fixes: `label` is optional and derived from content;
250
+ tool descriptions state the all-args-required contract.
251
+ - **Nudges leak into tool-less agent contexts**: task-dispatched sub-agents
252
+ (explore/general and friends) have restricted tool lists that exclude the
253
+ thatch tools, but `chat.message` nudges fired for them anyway, producing
254
+ guaranteed "No tool named ..." error rounds. The chat.message hook now
255
+ skips nudges for child sessions that are not extraction children.
256
+
257
+ The general lesson: any surface that names a tool to an LLM (system prompt,
258
+ nudge, skill text) is a usage-shape prior. Give the argument shape at the
259
+ mention site, because the model's first call happens before it has seen a
260
+ working example. Also note Code Mode `execute` validation failures are only
261
+ persisted in the v2 `session_message` store - they are invisible in the v1
262
+ `part` table, so error-rate analysis on old data undercounts them.
263
+
264
+ ## ThatchDB lazy reopen: new accessors must go through the private getters, and journal writes stay guarded at call sites
265
+
266
+ `ThatchDB` reopens its SQLite connection on first use after `close()`
267
+ (`src/db.ts`, the `#db`/`#predictions`/`#behaviors`/`#chat` private getters), so
268
+ an in-flight tool call that races a v2 plugin reload's `dispose()` completes
269
+ instead of throwing "Cannot use a closed database". Two rules keep this intact:
270
+
271
+ - **New facade methods need no extra wiring, but new state-holding members
272
+ do.** The engine facades (`ChatStore`, `PredictionEngine`, `BehaviorEngine`)
273
+ capture the `Database` handle at construction; the reopen rebuilds them. A
274
+ new member that captures the Database directly must get its own private
275
+ getter that ensures the handle is open first - a plain field keeps serving
276
+ the stale closed handle and the reopen silently never fires for it (this
277
+ exact bug is pinned by the "engine facades are rebuilt" test in
278
+ tests/db.test.ts).
279
+ - **Post-dispose journal writes are no-ops at their call sites, not in
280
+ db.ts.** After a reload, the RELOADED instance owns the `runtime_state`
281
+ rows, so delayed writers in `src/runtime.ts` check the `disposed` flag
282
+ BEFORE touching the db - the lazy reopen would otherwise hand a stale
283
+ writer a fresh connection. All three journal callbacks are guarded (the
284
+ extraction pipeline's finalization, the WatcherRegistry journal - a poll
285
+ cycle suspended at an await can outlive `watchers.dispose()` - and the
286
+ wrap-up arming write, whose command executes are not drained by the v2
287
+ cleanup). Do not "fix" the guard away by moving it into db.ts: the
288
+ accessor cannot tell a legitimate in-flight caller from a stale writer.
@@ -25,7 +25,7 @@ code (`src/opencode/v1.ts`):
25
25
  | `experimental.session.compacting` | `{ sessionID }` | `{ context: string[] }` | Marks the session as compacting, pushes re-familiarization context. |
26
26
  | `experimental.compaction.autocontinue` | `{ sessionID }` | `{ enabled: boolean }` | Clears the compacting flag so `chat.message` nudges resume post-compaction. |
27
27
  | `tool.execute.after` | `{ tool, sessionID, callID, args }` | `{ title, output, metadata }` | Buffers non-`thatch_`, non-`skill`, non-`task` tool calls into the in-memory extraction ring buffer (max 20). In child sessions, also tracks new/updated/deleted counts via `childMetrics` for the extraction toast. |
28
- | `chat.message` | `{ sessionID, messageID }` | `{ message, parts }` | Two tiers: extraction nudge if pending and not already extracting (fallback — direct extraction via the `event` hook is the primary path), else prompt-aware recall nudge. Suppressed while compacting or while `extracting` is set. |
28
+ | `chat.message` | `{ sessionID, messageID }` | `{ message, parts }` | Two tiers: extraction nudge if pending and not already extracting (fallback — direct extraction via the `event` hook is the primary path), else prompt-aware recall nudge. Suppressed while compacting, while `extracting` is set, or for task-dispatched sub-agent sessions (children that are not extraction children). |
29
29
  | `event` | `{ event: { type, properties } }` | — | Dispatches on `event.type`: `session.created` (top-level) → `client.session.prompt` with reminder + hygiene heartbeat; `session.created` (child with `parentID`) → records `childToParent` + snapshots parent's pending buffer. `session.status` idle (parent, pending interactions) → `triggerExtraction` (direct child-session extraction); idle (child) → complete/drain, `showToast` with metrics, delete child. `session.error` (child) → requeue parent's accepted entries. `session.deleted` → requeue if child, drop accepted if parent, clean maps. `session.compacted` → clear compacting flag. |
30
30
  | `dispose` | — | — | Closes the DB. |
31
31
 
@@ -104,7 +104,7 @@ Stores are created automatically. No setup required.
104
104
 
105
105
  | Tool | What it does |
106
106
  |------|-------------|
107
- | `thatch_memory_remember` | Save a piece of information. Label it, and thatch embeds it for later recall. |
107
+ | `thatch_memory_remember` | Save a piece of information; thatch embeds it for later recall. The label is optional - it is derived from the content's first heading or line when omitted. |
108
108
  | `thatch_memory_recall` | Search for relevant information using natural language. Searches both the current project's store and `global` by default. |
109
109
  | `thatch_memory_list` | List all memory labels in a store. |
110
110
  | `thatch_memory_show` | Read the full content of a memory by exact label. |
@@ -33,7 +33,7 @@ store when recalling.
33
33
 
34
34
  | Tool | What it does |
35
35
  |------|-------------|
36
- | `thatch_memory_remember` | Save a memory with a label and content. Optional: branch, confidence (1-10), archived flag, overwrite. |
36
+ | `thatch_memory_remember` | Save a memory with a label and content. The label may be omitted - it is derived from the content's first heading or line. Optional: branch, confidence (1-10), archived flag, overwrite. |
37
37
  | `thatch_memory_recall` | Semantic search across stores. Returns matching memories with similarity scores. |
38
38
  | `thatch_memory_list` | List all memories in a store (metadata only, no content). |
39
39
  | `thatch_memory_show` | Show full content of a single memory by label. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/chat.ts CHANGED
@@ -1371,6 +1371,23 @@ export function hostedSessionIds(options: {
1371
1371
  return [...new Set(hosted)].filter((id) => !excluded.has(id));
1372
1372
  }
1373
1373
 
1374
+ /**
1375
+ * Grace-rehost ids still inside their rehost window, pruning expired
1376
+ * entries as a side effect (the map is the runtime's live restart-grace
1377
+ * state - see CHAT_REHOST_GRACE_MS in runtime.ts). A restart re-hosts the
1378
+ * dead daemon's hosted set under this grace: open TUI tabs reconnect but
1379
+ * emit no events until the user types, so without it they go falsely
1380
+ * stale while alive-idle.
1381
+ */
1382
+ export function unexpiredGraceRehosts(grace: Map<string, number>, now: number): string[] {
1383
+ const live: string[] = [];
1384
+ for (const [id, deadline] of grace) {
1385
+ if (now >= deadline) grace.delete(id);
1386
+ else live.push(id);
1387
+ }
1388
+ return live;
1389
+ }
1390
+
1374
1391
  export class ChatPoller {
1375
1392
  #opts: Required<ChatPollerOptions>;
1376
1393
  #timer: ReturnType<typeof setInterval> | null = null;
package/src/db.ts CHANGED
@@ -55,12 +55,67 @@ export interface DedupCandidate {
55
55
  * stored as BLOBs. Similarity search is brute-force cosine in JS.
56
56
  */
57
57
  export class ThatchDB {
58
- #db: Database;
59
- #predictions: PredictionEngine;
60
- #behaviors: BehaviorEngine;
61
- #chat: ChatStore;
58
+ #path: string;
59
+ #handle: Database | null = null;
60
+ #predictionsStore!: PredictionEngine;
61
+ #behaviorsStore!: BehaviorEngine;
62
+ #chatStore!: ChatStore;
62
63
 
63
64
  constructor(path: string) {
65
+ this.#path = path;
66
+ this.#open();
67
+ }
68
+
69
+ /**
70
+ * Connection accessor. Every read goes through here, so a handle closed by
71
+ * dispose() (a v2 plugin reload tears down the old activation while tool
72
+ * calls may still be in flight) transparently reopens on the next touch
73
+ * instead of surfacing bun:sqlite's "Cannot use a closed database". WAL
74
+ * journaling plus busy_timeout make the extra connection safe - the chat
75
+ * subsystem already runs as several concurrent connections by design.
76
+ *
77
+ * OWNERSHIP RULE: reopen serves READS and in-flight callers only. After a
78
+ * reload, the RELOADED instance owns the runtime_state journal rows, so
79
+ * post-dispose journal writes must no-op - they are guarded by the
80
+ * disposed flag at their call sites in src/runtime.ts, checked BEFORE any
81
+ * db access reaches this accessor. Do not move that guard in here: this
82
+ * method cannot distinguish a legitimate in-flight caller from a stale
83
+ * writer.
84
+ *
85
+ * Reopen caveat: a `:memory:` database has no file behind it, so a
86
+ * close-then-use cycle on one yields a brand-new EMPTY database. No
87
+ * production path does that (:memory: is test-only and tests never use the
88
+ * instance after close), but keep it that way - use a tmp-file db if a
89
+ * test needs close-then-use.
90
+ */
91
+ get #db(): Database {
92
+ if (this.#handle === null) this.#open();
93
+ return this.#handle!;
94
+ }
95
+
96
+ // The engine facades get the same treatment as #db: every accessor
97
+ // ensures an open connection first, so delegation routes through the
98
+ // facade REBUILT by the reopen rather than the stale one. Without this,
99
+ // a post-close chat call would reach the old ChatStore's captured handle
100
+ // and throw "Cannot use a closed database" even though ThatchDB itself
101
+ // reopened.
102
+ get #predictions(): PredictionEngine {
103
+ if (this.#handle === null) this.#open();
104
+ return this.#predictionsStore;
105
+ }
106
+
107
+ get #behaviors(): BehaviorEngine {
108
+ if (this.#handle === null) this.#open();
109
+ return this.#behaviorsStore;
110
+ }
111
+
112
+ get #chat(): ChatStore {
113
+ if (this.#handle === null) this.#open();
114
+ return this.#chatStore;
115
+ }
116
+
117
+ #open(): void {
118
+ const path = this.#path;
64
119
  // bun:sqlite creates the file but not its parent directory. On a fresh
65
120
  // machine the default path (~/.config/thatch/thatch.db) has no dir yet, so
66
121
  // create it here - otherwise every command fails with SQLITE_CANTOPEN.
@@ -68,18 +123,23 @@ export class ThatchDB {
68
123
  if (path !== ":memory:" && !path.startsWith("file:")) {
69
124
  mkdirSync(dirname(path), { recursive: true });
70
125
  }
71
- this.#db = new Database(path, { create: true });
72
- this.#db.run("PRAGMA journal_mode = WAL");
73
- this.#db.run("PRAGMA busy_timeout = 5000");
126
+ const db = new Database(path, { create: true });
127
+ db.run("PRAGMA journal_mode = WAL");
128
+ db.run("PRAGMA busy_timeout = 5000");
74
129
  // Enforce FK constraints per connection. The prediction tables use
75
130
  // ON DELETE CASCADE for edges and provenance; enabling this pragma
76
131
  // ensures those cascades fire. Also enforces the entries table's
77
132
  // existing FK to stores(name), which was declarative but unenforced
78
133
  // before this branch.
79
- this.#db.run("PRAGMA foreign_keys = ON");
80
- this.#predictions = new PredictionEngine(this.#db);
81
- this.#behaviors = new BehaviorEngine(this.#db);
82
- this.#chat = new ChatStore(this.#db);
134
+ db.run("PRAGMA foreign_keys = ON");
135
+ this.#handle = db;
136
+ // The engine facades hold the Database handle they were constructed
137
+ // with, so a reopened connection rebuilds them. They are stateless
138
+ // views over SQLite - all durable state lives in the tables - so
139
+ // reconstruction is lossless.
140
+ this.#predictionsStore = new PredictionEngine(db);
141
+ this.#behaviorsStore = new BehaviorEngine(db);
142
+ this.#chatStore = new ChatStore(db);
83
143
  this.#initSchema();
84
144
  }
85
145
 
@@ -1169,7 +1229,12 @@ export class ThatchDB {
1169
1229
  }
1170
1230
 
1171
1231
  close(): void {
1172
- this.#db.close();
1232
+ // Drops the current handle only. A later use reopens (see the #db
1233
+ // accessor) - the MCP server and CLI call this on process shutdown,
1234
+ // where nothing follows; the opencode plugin's dispose calls it on
1235
+ // reload, where straggler in-flight tool calls must survive.
1236
+ this.#handle?.close();
1237
+ this.#handle = null;
1173
1238
  }
1174
1239
  }
1175
1240
 
package/src/extraction.ts CHANGED
@@ -233,6 +233,93 @@ export class ExtractionPipeline {
233
233
  this.#journal?.("accepted", sessionID, undefined);
234
234
  }
235
235
 
236
+ #claims = new Map<string, { target: string; entries: ToolInteraction[] }>();
237
+
238
+ /**
239
+ * Claim the session's pending + previously-accepted entries for a fetcher
240
+ * (the extractor sub-agent fetching its payload). The entries stay in the
241
+ * accepted holding area; the claim records WHICH fetcher they were
242
+ * delivered to, so that fetcher's completion signal consumes only its own
243
+ * delivery. Without this, any child's completion dropped the whole
244
+ * accepted set - a sibling sub-agent going idle at the wrong moment
245
+ * silently discarded another extractor's in-flight payload (the
246
+ * accept-then-racing-completion loss, observed September 2026).
247
+ *
248
+ * Entries are claimed by reference identity: later accepts append new
249
+ * arrays, so a stale claim never reaches entries claimed after it.
250
+ */
251
+ claim(sessionID: string, fetcherID: string): ToolInteraction[] {
252
+ this.accept(sessionID);
253
+ const accepted = this.#accepted.get(sessionID) ?? [];
254
+ this.#claims.set(fetcherID, { target: sessionID, entries: accepted });
255
+ return accepted;
256
+ }
257
+
258
+ /**
259
+ * Consume only the fetcher's claimed entries. Returns false when the
260
+ * fetcher holds no claim - a sibling that never fetched, or a fetch that
261
+ * never happened. Callers must NOT fall back to whole-set completion on
262
+ * the idle path: a fetcher that never fetched processed nothing, and
263
+ * dropping the set would lose another extractor's claimed payload.
264
+ */
265
+ completeClaimed(fetcherID: string): boolean {
266
+ const claim = this.#claims.get(fetcherID);
267
+ if (!claim) return false;
268
+ this.#claims.delete(fetcherID);
269
+ const accepted = this.#accepted.get(claim.target);
270
+ if (accepted) {
271
+ const claimed = new Set(claim.entries);
272
+ const rest = accepted.filter((ix) => !claimed.has(ix));
273
+ if (rest.length > 0) {
274
+ this.#accepted.set(claim.target, rest);
275
+ this.#journal?.("accepted", claim.target, rest);
276
+ } else {
277
+ this.#accepted.delete(claim.target);
278
+ this.#acceptedAt.delete(claim.target);
279
+ this.#journal?.("accepted", claim.target, undefined);
280
+ }
281
+ }
282
+ return true;
283
+ }
284
+
285
+ /** Forget a fetcher's claim (its session was deleted or cleaned up). */
286
+ dropClaim(fetcherID: string): void {
287
+ this.#claims.delete(fetcherID);
288
+ }
289
+
290
+ /**
291
+ * Requeue only the fetcher's claimed entries back to pending: its session
292
+ * errored, so the delivery was never processed. Returns false when the
293
+ * fetcher holds no claim - a task-kind child that never fetched processed
294
+ * nothing, and requeueing the whole accepted set here would yank another
295
+ * extractor's in-flight claim back to pending for duplicate extraction.
296
+ * Callers decide whether a claim-less error falls back to whole-set
297
+ * requeue (extraction children: yes - the error is a strong immediate
298
+ * death signal; task children: no).
299
+ */
300
+ requeueClaimed(fetcherID: string): boolean {
301
+ const claim = this.#claims.get(fetcherID);
302
+ if (!claim) return false;
303
+ this.#claims.delete(fetcherID);
304
+ const accepted = this.#accepted.get(claim.target);
305
+ if (accepted) {
306
+ const claimed = new Set(claim.entries);
307
+ const rest = accepted.filter((ix) => !claimed.has(ix));
308
+ if (rest.length > 0) {
309
+ this.#accepted.set(claim.target, rest);
310
+ this.#journal?.("accepted", claim.target, rest);
311
+ } else {
312
+ this.#accepted.delete(claim.target);
313
+ this.#acceptedAt.delete(claim.target);
314
+ this.#journal?.("accepted", claim.target, undefined);
315
+ }
316
+ }
317
+ const buf = this.#buffers.get(claim.target) ?? [];
318
+ this.#buffers.set(claim.target, [...claim.entries, ...buf]);
319
+ this.#journal?.("buffer", claim.target, this.#buffers.get(claim.target));
320
+ return true;
321
+ }
322
+
236
323
  /**
237
324
  * Move accepted entries back to pending: the extractor errored or its
238
325
  * session was deleted before completing, so the facts were never
@@ -254,9 +341,8 @@ export class ExtractionPipeline {
254
341
  /**
255
342
  * Requeues accepted entries that have waited longer than maxAgeMs for a
256
343
  * completion signal that never came: the extractor sub-agent crashed
257
- * without acking, or (v2) completed without a parent linkage the plugin
258
- * could see. Without this, accepted entries linger unprocessed AND
259
- * un-nudged - silent loss. Returns the session ids that had entries
344
+ * without acking, or the completion ack was lost. Without this, accepted
345
+ * entries linger unprocessed AND un-nudged - silent loss. Returns the session ids that had entries
260
346
  * requeued; the regular nudge path picks them up from pending on the
261
347
  * session's next message.
262
348
  */
package/src/index.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  // SDK. The pure helpers re-exported here live in SDK-free modules for the
15
15
  // same reason.
16
16
 
17
+ import { appendFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
17
19
  import type { Plugin } from "@opencode-ai/plugin";
18
20
  import type { Plugin as V2Plugin } from "@opencode/plugin";
19
21
 
@@ -27,6 +29,49 @@ export {
27
29
  } from "./os-args";
28
30
  export { hygieneReport } from "./hygiene";
29
31
 
32
+ // One-per-process unhandledRejection logger, opt-in via THATCH_DEBUG. The
33
+ // host logs these bare ("unhandled rejection" + message, no stack), which is
34
+ // exactly wrong for diagnosing which async continuation in the plugin
35
+ // escaped its error handling - the dispose-race class in particular escapes
36
+ // through several surfaces (pollers, hooks, delayed finalizers). Writes to
37
+ // the debug log beside the database (same file createDebugLog uses), tagged
38
+ // so a grep on "[rejection:" pulls the story out.
39
+ //
40
+ // Registered at module scope because rejections can fire before or after
41
+ // setup runs, and once per process because v2 re-evaluates this module on
42
+ // every plugin reload (a bare process.on would pile up listeners). The key
43
+ // is a Symbol.for so the guard survives module re-evaluation in the same
44
+ // thread; worker threads have their own globalThis and process, and one
45
+ // logger per thread is correct anyway. Registering the listener marks the
46
+ // rejections "handled" - suppressing the host's default crash-on-unhandled -
47
+ // so the handler also echoes the detail to console.error to keep the
48
+ // failure visible while diagnosing.
49
+ const rejectionLoggerKey = Symbol.for("thatch.unhandledRejectionLogger");
50
+ if (process.env.THATCH_DEBUG && !(globalThis as any)[rejectionLoggerKey]) {
51
+ const home = process.env.HOME ?? "/tmp";
52
+ const configHome = process.env.XDG_CONFIG_HOME ?? `${home}/.config`;
53
+ const dbPath = process.env.THATCH_DB_PATH ?? `${configHome}/thatch/thatch.db`;
54
+ // A file: URI db path has no meaningful sibling debug.log (appendFileSync
55
+ // cannot open "file:.../debug.log"); skip logging rather than silently
56
+ // drop every rejection into the catch below.
57
+ (globalThis as any)[rejectionLoggerKey] = !dbPath.startsWith("file:");
58
+ if ((globalThis as any)[rejectionLoggerKey]) {
59
+ const file = join(dirname(dbPath), "debug.log");
60
+ process.on("unhandledRejection", (reason) => {
61
+ const detail =
62
+ reason instanceof Error
63
+ ? `${reason.message}\n${reason.stack ?? "(no stack)"}`
64
+ : String(reason);
65
+ console.error(`[thatch] unhandled rejection: ${detail.split("\n")[0]}`);
66
+ try {
67
+ appendFileSync(file, `${new Date().toISOString()} [rejection:unhandled] ${detail}\n`);
68
+ } catch {
69
+ // Diagnostics must not break the host (same contract as debug.log).
70
+ }
71
+ });
72
+ }
73
+ }
74
+
30
75
  // Named v1 export, preserved for the shim and tests. Lazy wrapper: never
31
76
  // evaluate the v1 adapter (and its SDK import) under a v2 host.
32
77
  export const server: Plugin = async (input, options) => (await import("./opencode/v1")).server(input, options);