@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.
- package/artifacts/skills/thatch-fact-extractor.md +1 -1
- package/artifacts/skills/thatch-memory-verify.md +2 -2
- package/docs/dev/README.md +11 -7
- package/docs/dev/features/cross-session-chat.md +8 -1
- package/docs/dev/features/memory-store.md +1 -1
- package/docs/dev/features/nudge-pipeline.md +12 -0
- package/docs/dev/features/opencode-plugin.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +58 -1
- package/docs/dev/setup-and-hooks.md +1 -1
- package/docs/user/README.md +1 -1
- package/docs/user/memory.md +1 -1
- package/package.json +1 -1
- package/src/chat.ts +17 -0
- package/src/db.ts +77 -12
- package/src/extraction.ts +89 -3
- package/src/index.ts +45 -0
- package/src/opencode/v2.ts +48 -4
- package/src/prompts.ts +104 -30
- package/src/runtime.ts +261 -72
- package/src/tool-defs.ts +156 -28
- package/src/watchers.ts +14 -0
|
@@ -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."
|
|
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
|
|
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
|
|
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.
|
package/docs/dev/README.md
CHANGED
|
@@ -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).
|
|
183
|
-
fact-extractor skill's return value is
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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).
|
|
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
|
|
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`
|
package/docs/dev/gotchas.md
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
package/docs/user/README.md
CHANGED
|
@@ -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
|
|
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. |
|
package/docs/user/memory.md
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
this.#
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
258
|
-
*
|
|
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);
|