@jeffober/thatch 0.2.2 → 0.2.4
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/bin/thatch +77 -46
- package/docs/dev/README.md +21 -25
- package/docs/dev/features/cli.md +1 -0
- package/docs/dev/features/extraction.md +120 -81
- package/docs/dev/features/nudge-pipeline.md +44 -39
- package/docs/dev/features/session-lifecycle.md +34 -20
- package/docs/dev/features/setup.md +7 -0
- package/docs/dev/features/watchers.md +9 -0
- package/docs/dev/gotchas.md +20 -26
- package/docs/dev/mcp-parity.md +16 -17
- package/docs/dev/setup-and-hooks.md +1 -1
- package/docs/user/cli.md +2 -1
- package/docs/user/setup.md +13 -0
- package/docs/user/watchers.md +6 -0
- package/package.json +1 -1
- package/src/chat.ts +1 -1
- package/src/extract-queue.ts +16 -1
- package/src/extraction.ts +4 -3
- package/src/opencode/v2.ts +4 -3
- package/src/prompts.ts +12 -33
- package/src/runtime.ts +110 -107
- package/src/setup.ts +50 -0
- package/src/tool-defs.ts +39 -31
- package/src/watchers.ts +31 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Unless noted, `client.*` names below are the opencode v1 mapping; the v2 equivalents and degrades are in [opencode-plugin.md](opencode-plugin.md).
|
|
4
4
|
|
|
5
|
-
Runs on every user message.
|
|
5
|
+
Runs on every user message. Priority tiers share one embedding
|
|
6
6
|
computation. The pipeline injects *synthetic parts*---text the model sees in
|
|
7
7
|
the conversation but the user does not see in the TUI. This is the inverse of
|
|
8
8
|
toast notifications, which are TUI-visible but model-invisible.
|
|
@@ -10,16 +10,18 @@ toast notifications, which are TUI-visible but model-invisible.
|
|
|
10
10
|
The nudge pipeline is thatch's core innovation. It is not a passive
|
|
11
11
|
store-and-retrieve system. It is a nudge layer that changes agent behavior by
|
|
12
12
|
injecting context at the right moment. See [extraction.md](extraction.md) for
|
|
13
|
-
the extraction
|
|
13
|
+
the extraction pipeline (plugin-driven on opencode; nudged on MCP hosts only),
|
|
14
|
+
[prediction-engine.md](prediction-engine.md) for the
|
|
14
15
|
prediction auto-fire, and [behavior-engine.md](behavior-engine.md) for the
|
|
15
16
|
behavior auto-fire.
|
|
16
17
|
|
|
17
18
|
## What it does
|
|
18
19
|
|
|
19
|
-
On every user message, thatch runs through
|
|
20
|
+
On every user message, thatch runs through the priority tiers:
|
|
20
21
|
|
|
21
|
-
1. **Extraction nudge
|
|
22
|
-
|
|
22
|
+
1. **Extraction nudge** (MCP hosts only)---if tool interactions are queued,
|
|
23
|
+
tell the agent to dispatch an extractor. There is deliberately no
|
|
24
|
+
opencode extraction nudge: extraction is plugin-driven at session idle.
|
|
23
25
|
2. **Recall nudge**---semantically search stored memories for context
|
|
24
26
|
relevant to the user's prompt.
|
|
25
27
|
3. **Prediction nudge**---surface user decision-model predictions relevant to
|
|
@@ -28,14 +30,16 @@ On every user message, thatch runs through four priority tiers:
|
|
|
28
30
|
current situation.
|
|
29
31
|
|
|
30
32
|
Tiers 2--4 fire independently and share one embedding computation. Tier 1
|
|
31
|
-
|
|
33
|
+
(extraction) exists only on the MCP path.
|
|
32
34
|
|
|
33
35
|
## How it works
|
|
34
36
|
|
|
35
|
-
Two host paths deliver the
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
Two host paths deliver the tiers through different mechanisms. The opencode
|
|
38
|
+
path runs in-process with direct event hooks and has THREE tiers (recall,
|
|
39
|
+
prediction, behavior - extraction is plugin-driven at session idle, not
|
|
40
|
+
nudged). The MCP path runs via external CLI hook processes that communicate
|
|
41
|
+
with the long-lived MCP server through the sideband socket and keeps all
|
|
42
|
+
four.
|
|
39
43
|
|
|
40
44
|
### opencode path (`src/runtime.ts`, `onChatMessage` - v1 `chat.message` hook, v2 `session.hook("prompt")`)
|
|
41
45
|
|
|
@@ -53,28 +57,25 @@ See [compaction-recovery.md](compaction-recovery.md).
|
|
|
53
57
|
|
|
54
58
|
If the session is a child in `childToParent` but not in `extractionChildren`
|
|
55
59
|
(a task-dispatched sub-agent), all nudge tiers are skipped and the hook
|
|
56
|
-
returns
|
|
60
|
+
returns early. Dispatched sub-agents may have restricted tool lists
|
|
57
61
|
that exclude the thatch tools, and even where the tools exist the child is
|
|
58
62
|
driven by its dispatch prompt, so a nudge only produces "No tool named"
|
|
59
|
-
error rounds or noise.
|
|
60
|
-
the guard - it is session-independent maintenance. See
|
|
63
|
+
error rounds or noise. See
|
|
61
64
|
[session-lifecycle.md](session-lifecycle.md) for the `extractionChildren`
|
|
62
65
|
distinction.
|
|
63
66
|
|
|
64
|
-
#### Tier 1---Extraction nudge
|
|
67
|
+
#### Tier 1---Extraction nudge: REMOVED from opencode
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
-
|
|
73
|
-
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
See [extraction.md](extraction.md) for the full extraction pipeline, including
|
|
77
|
-
the accept/complete/requeue lifecycle and the `extracting` set.
|
|
69
|
+
There is deliberately no extraction nudge on the opencode path. The old
|
|
70
|
+
model-driven handshake (nudge → model dispatches a sub-agent → parent acks
|
|
71
|
+
with `thatch_extraction_done` → child fetches by a model-copied session ID)
|
|
72
|
+
raced its own state machine: the parent's ack accepted the buffer before
|
|
73
|
+
the child fetched, a no-claim completion wiped the accepted set, and
|
|
74
|
+
mis-targeted IDs made every transition a silent no-op - so the same queued
|
|
75
|
+
count re-fired forever while buffered interactions were silently lost (the
|
|
76
|
+
September 2026 dispatch-loop report, both defects). Extraction on opencode
|
|
77
|
+
is now plugin-driven end-to-end (`triggerExtraction` at session idle); see
|
|
78
|
+
[extraction.md](extraction.md). The MCP path keeps its nudge - see below.
|
|
78
79
|
|
|
79
80
|
#### Tier 2---Recall nudge
|
|
80
81
|
|
|
@@ -127,8 +128,7 @@ and the self-discipline rule data model.
|
|
|
127
128
|
#### Tier independence
|
|
128
129
|
|
|
129
130
|
Tiers 2, 3, and 4 all fire independently. Any subset may inject. All share
|
|
130
|
-
one embedding computation
|
|
131
|
-
extraction tier returns early, and is reused across three cosine scans against
|
|
131
|
+
one embedding computation, reused across three cosine scans against
|
|
132
132
|
different tables.
|
|
133
133
|
|
|
134
134
|
### MCP path (`bin/thatch`, `flush-tools` subcommand)
|
|
@@ -156,10 +156,14 @@ Three priority tiers, first match wins:
|
|
|
156
156
|
#### Tier 1---Extraction nudge
|
|
157
157
|
|
|
158
158
|
- `peekQueue(sessionID)` peeks the file-backed JSONL queue without draining
|
|
159
|
-
it. The queue persists until the
|
|
160
|
-
|
|
159
|
+
it. The queue persists until the extractor completes
|
|
160
|
+
(`extraction_done` with the parent's session id) or the agent calls
|
|
161
|
+
`memory_remember`, which triggers `consumeQueue`. The parent's
|
|
162
|
+
dispatch-time ack resets the escalation counter but does NOT drain - a
|
|
163
|
+
drain at ack time deleted the queue before the sub-agent fetched (the
|
|
164
|
+
accept-before-fetch loss).
|
|
161
165
|
- If interactions are pending: increments the missed count, prints
|
|
162
|
-
`extractionNudge(count, missed,
|
|
166
|
+
`extractionNudge(count, missed, sessionID)`.
|
|
163
167
|
- Breaks. Tiers 2--3 are skipped.
|
|
164
168
|
|
|
165
169
|
See [extraction.md](extraction.md) for the file-backed queue and the
|
|
@@ -253,9 +257,10 @@ TUI display. The opencode framework's history serializer filters on
|
|
|
253
257
|
|
|
254
258
|
## Interactions with other features
|
|
255
259
|
|
|
256
|
-
- **Extraction pipeline** ([extraction.md](extraction.md)): tier 1
|
|
257
|
-
extraction nudge.
|
|
258
|
-
extraction is
|
|
260
|
+
- **Extraction pipeline** ([extraction.md](extraction.md)): the MCP tier 1
|
|
261
|
+
is the extraction nudge. On opencode there is no extraction
|
|
262
|
+
nudge - extraction is plugin-driven at session idle, so `chat.message`
|
|
263
|
+
runs the recall/prediction/behavior tiers only.
|
|
259
264
|
- **Memory store** ([memory-store.md](memory-store.md)): tier 2 searches
|
|
260
265
|
stored memories via `db.search()`. This path records no telemetry---the
|
|
261
266
|
agent has not read the memories yet.
|
|
@@ -272,21 +277,21 @@ TUI display. The opencode framework's history serializer filters on
|
|
|
272
277
|
the nudge pipeline is suppressed during compaction. The compaction guard
|
|
273
278
|
clears stale flags when compaction fails.
|
|
274
279
|
- **Session lifecycle** ([session-lifecycle.md](session-lifecycle.md)):
|
|
275
|
-
direct extraction (`triggerExtraction`)
|
|
276
|
-
|
|
280
|
+
direct extraction (`triggerExtraction`) is triggered by session idle;
|
|
281
|
+
there is no extraction tier to suppress on opencode.
|
|
277
282
|
|
|
278
283
|
## Source files
|
|
279
284
|
|
|
280
285
|
| File | Role |
|
|
281
286
|
|------|------|
|
|
282
|
-
| `src/runtime.ts` | opencode: per-message nudges (
|
|
283
|
-
| `bin/thatch` | MCP: `flush-tools` and `flush-predictions` subcommands |
|
|
287
|
+
| `src/runtime.ts` | opencode: per-message nudges (recall/prediction/behavior tiers; no extraction nudge), compaction guard |
|
|
288
|
+
| `bin/thatch` | MCP: `flush-tools` and `flush-predictions` subcommands (all 4 tiers) |
|
|
284
289
|
| `src/sideband.ts` | MCP: sideband client helpers (`sidebandMatch`, `sidebandPredictions`, `sidebandBehaviors`) |
|
|
285
290
|
| `src/prompts.ts` | All nudge formatting functions (`recallNudge`, `claudeRecallNudge`, `predictionNudge`, `behaviorNudge`, `extractionNudge`, `claudeWriteNudge`) |
|
|
286
291
|
|
|
287
292
|
## Key invariants
|
|
288
293
|
|
|
289
|
-
- Tier 1 (extraction) returns early. Tiers 2-4 are skipped when tier 1 fires.
|
|
294
|
+
- Tier 1 (extraction, MCP only) returns early. Tiers 2-4 are skipped when tier 1 fires.
|
|
290
295
|
- Tiers 2-4 share one embedding computation. One embed, three cosine scans against different tables.
|
|
291
296
|
- Each tier is wrapped in its own try/catch. A failure in one tier does not block the others.
|
|
292
297
|
- The recall nudge uses `db.search()`, not `db.recall()`, to avoid inflating telemetry. The agent has not read the memories yet.
|
|
@@ -27,7 +27,10 @@ Subscribes to all session bus events. Dispatches based on `event.type`.
|
|
|
27
27
|
2. Snapshot the parent's current pending buffer: `parentSnapshots.set(childID, [...extraction.peek(parentID)])`
|
|
28
28
|
3. Return early -- child sessions don't get the session-start reminder
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
The snapshot is journal-recovery data only (restored child bookkeeping
|
|
31
|
+
after a reload); live delivery is claim-based: the child's payload fetch
|
|
32
|
+
records exactly which entries it received, and its completion signal
|
|
33
|
+
consumes only those.
|
|
31
34
|
|
|
32
35
|
### session.created without parentID (top-level session)
|
|
33
36
|
|
|
@@ -39,20 +42,22 @@ This snapshot is used by `consumeSnapshot` for snapshot-aware drain: when the ch
|
|
|
39
42
|
|
|
40
43
|
When a child created by `triggerExtraction` goes idle:
|
|
41
44
|
|
|
42
|
-
1.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
1. `completeClaimed(childID)` -- consume only the entries this child
|
|
46
|
+
claimed via its payload fetch. A no-claim idle is a no-op: a child that
|
|
47
|
+
never fetched processed nothing, and its idle signal must not drop
|
|
48
|
+
entries another extractor holds. Held entries with no completer are
|
|
49
|
+
bounded by the 15-minute stale reaper
|
|
50
|
+
2. Fire a toast with extraction metrics (new/updated/deleted counts) -- only if memories were actually written
|
|
51
|
+
3. Clean up all maps: `extracting`, `childToParent`, `parentSnapshots`, `childMetrics`, `extractionChildren`
|
|
52
|
+
4. `consume(childID)` -- drain child's own buffer
|
|
53
|
+
5. Delete the child session via `client.session.delete`
|
|
49
54
|
|
|
50
55
|
### session.status idle -- task-dispatched sub-agent
|
|
51
56
|
|
|
52
57
|
When a task-dispatched sub-agent (not created by `triggerExtraction`) goes idle:
|
|
53
58
|
|
|
54
|
-
- `
|
|
55
|
-
-
|
|
59
|
+
- `completeClaimed(childID)` -- complete only what THIS child claimed via
|
|
60
|
+
its payload fetch (a no-claim idle consumes nothing)
|
|
56
61
|
- Does NOT drain the buffer or delete the session -- the task tool that dispatched the sub-agent needs to read its output
|
|
57
62
|
|
|
58
63
|
### session.status idle -- parent session
|
|
@@ -60,8 +65,9 @@ When a task-dispatched sub-agent (not created by `triggerExtraction`) goes idle:
|
|
|
60
65
|
When a parent session (no parentID) goes idle:
|
|
61
66
|
|
|
62
67
|
- Wrap-up resolution first (below): a pending `/thatch/compact` or `/thatch/exit` resolves here, and a greenlit action returns early
|
|
68
|
+
- `requeueStaleAccepted()` first: accepted entries whose completion signal never came (15 min) return to pending
|
|
63
69
|
- If not compacting, not already extracting, and buffer has pending interactions: `triggerExtraction(sessionID)`
|
|
64
|
-
- On failure: log error, clear `extracting` flag (
|
|
70
|
+
- On failure: log error, clear `extracting` flag (the next idle retries -- there is no model-facing nudge fallback)
|
|
65
71
|
|
|
66
72
|
### Wrap-up commands (`/thatch/compact`, `/thatch/exit`)
|
|
67
73
|
|
|
@@ -84,12 +90,21 @@ first-ever install is invisible until the next server start.
|
|
|
84
90
|
|
|
85
91
|
### session.error (child session)
|
|
86
92
|
|
|
87
|
-
- `
|
|
93
|
+
- `requeueClaimed(childID)` if it fetched (the delivery was never
|
|
94
|
+
processed), else `requeueAccepted(parentID)` -- the error is a terminal
|
|
95
|
+
death signal, so a claim-less child falls back to whole-set requeue for
|
|
96
|
+
immediate recovery
|
|
97
|
+
- Clear `extracting` for the parent (the next idle re-triggers)
|
|
88
98
|
- Clean up all maps for the child
|
|
89
99
|
|
|
90
100
|
### session.deleted -- child
|
|
91
101
|
|
|
92
|
-
- `
|
|
102
|
+
- Extraction child: `requeueClaimed(childID)`, else
|
|
103
|
+
`requeueAccepted(parentID)` (never processed)
|
|
104
|
+
- Task-kind child: `requeueClaimed(childID)` only -- task children are
|
|
105
|
+
deleted ROUTINELY, so a claim-less task child's deletion must not requeue
|
|
106
|
+
the whole accepted set (it would yank an in-flight extractor's payload
|
|
107
|
+
back to pending for duplicate extraction)
|
|
93
108
|
- Clean up all maps for the child
|
|
94
109
|
|
|
95
110
|
### session.deleted -- parent
|
|
@@ -106,21 +121,20 @@ first-ever install is invisible until the next server start.
|
|
|
106
121
|
### Internal state maps
|
|
107
122
|
|
|
108
123
|
- **childToParent**: `Map<childID, parentID>` -- maps child sessions to their parents
|
|
109
|
-
- **parentSnapshots**: `Map<childID, Interaction[]>` -- snapshot of parent's buffer at child creation time
|
|
124
|
+
- **parentSnapshots**: `Map<childID, Interaction[]>` -- snapshot of parent's buffer at child creation time (journal recovery only; live delivery is claim-based)
|
|
110
125
|
- **childMetrics**: `Map<childID, {new, updated, deleted}>` -- extraction metrics per child
|
|
111
|
-
- **extracting**: `Set<parentID>` -- parent IDs with an active direct-extraction child (
|
|
126
|
+
- **extracting**: `Set<parentID>` -- parent IDs with an active direct-extraction child (gates re-triggering)
|
|
112
127
|
- **extractionChildren**: `Set<childID>` -- distinguishes extraction children from task-dispatched sub-agents
|
|
113
128
|
- **compacting**: `Set<sessionID>` -- sessions currently being compacted (suppresses nudges)
|
|
114
129
|
- **pendingWrapUp**: `Map<sessionID, {token, kind}>` -- wrap-up commands awaiting their greenlight check; armed by `command.execute.before`, resolved on the next idle, cleared on session deletion
|
|
115
|
-
- **missedNudges**: `Map<sessionID, number>` -- extraction nudge escalation counter
|
|
116
130
|
|
|
117
131
|
## Interactions with other features
|
|
118
132
|
|
|
119
133
|
- 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)): `
|
|
134
|
+
- Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `compacting` set suppresses all nudges; task-dispatched sub-agents (in `childToParent`, not in `extractionChildren`) suppress all tiers
|
|
121
135
|
- Hygiene ([hygiene.md](hygiene.md)): hygiene report runs at `session.created` for top-level sessions
|
|
122
136
|
- Compaction recovery ([compaction-recovery.md](compaction-recovery.md)): `session.compacted` event clears the compacting flag
|
|
123
|
-
- Memory store ([memory-store.md](memory-store.md)): child sessions write memories via `memory_remember`, which
|
|
137
|
+
- Memory store ([memory-store.md](memory-store.md)): child sessions write memories via `memory_remember`, which completes their claimed delivery via `tool.execute.after`
|
|
124
138
|
|
|
125
139
|
## Source files
|
|
126
140
|
|
|
@@ -130,7 +144,7 @@ first-ever install is invisible until the next server start.
|
|
|
130
144
|
## Key invariants
|
|
131
145
|
|
|
132
146
|
- Child sessions don't get the session-start reminder (early return in `session.created` with parentID).
|
|
133
|
-
-
|
|
147
|
+
- Delivery is claim-based: the payload fetch records what the fetcher received; only the fetcher's completion consumes it. A no-claim completion is a no-op, never a whole-set drop.
|
|
134
148
|
- Extraction children are deleted after going idle; task-dispatched sub-agents are NOT deleted (task tool reads output).
|
|
135
|
-
- Child errors requeue the
|
|
149
|
+
- Child errors requeue what the child held (never processed). Child deletion also requeues.
|
|
136
150
|
- Parent deletion completes accepted entries (takes them with it).
|
|
@@ -18,6 +18,8 @@ resolution fit together.
|
|
|
18
18
|
hooks, and skills for Cursor
|
|
19
19
|
- `--global` flag --- installs to user-scoped config dirs instead of
|
|
20
20
|
project-local
|
|
21
|
+
- `--skills-only` flag --- refreshes just the skill files (same host and
|
|
22
|
+
scope rules), skipping MCP config, instructions, hooks, and commands
|
|
21
23
|
- **Marker system** --- idempotent replacement of the thatch instruction block
|
|
22
24
|
in `CLAUDE.md` or `AGENTS.md` via start/end markers
|
|
23
25
|
- **`checkSetup`** --- detects installed, not-installed, or markers-broken at
|
|
@@ -35,6 +37,11 @@ resolution fit together.
|
|
|
35
37
|
|
|
36
38
|
At least one of `--claude` or `--cursor` is required. Both can be passed
|
|
37
39
|
together. `--global` applies to whichever host or hosts are selected.
|
|
40
|
+
`--skills-only` narrows the run to the skill install (`setupSkillsOnly`
|
|
41
|
+
in `src/setup.ts`): it reuses the same path resolution and other-scope
|
|
42
|
+
probe as the full setup paths but only calls `installSkills`, so it is
|
|
43
|
+
safe to run in an already-set-up host when only the skill files need
|
|
44
|
+
refreshing.
|
|
38
45
|
|
|
39
46
|
### Claude Code setup (`setupClaudeCode` in `src/setup.ts`)
|
|
40
47
|
|
|
@@ -73,6 +73,15 @@ gh setup fails registration with a real error instead of producing a
|
|
|
73
73
|
watcher that never fires. Watch creation is also gated on a cached
|
|
74
74
|
`gh --version` availability check.
|
|
75
75
|
|
|
76
|
+
CI events (`pr_ci`, `branch_ci`) fire only on a running -> completed
|
|
77
|
+
check-run transition, so a watcher registered after a fast pipeline has
|
|
78
|
+
already finished holds completed runs in its baseline and can never
|
|
79
|
+
notify for that head. When every check run on the head is already
|
|
80
|
+
completed at registration, the tool response says so, with the
|
|
81
|
+
conclusion breakdown (`describeBaselineCheckRuns`), so the caller reads
|
|
82
|
+
the result instead of waiting on a notification that cannot come. The
|
|
83
|
+
rule for catching a push's CI: register the watcher before pushing.
|
|
84
|
+
|
|
76
85
|
### Polling
|
|
77
86
|
|
|
78
87
|
A `setInterval` loop (unref'd - it never keeps the process alive)
|
package/docs/dev/gotchas.md
CHANGED
|
@@ -67,32 +67,26 @@ here first. These are the things that have already cost time.
|
|
|
67
67
|
- **Hook failures are logged with a `[thatch]` prefix and never swallowed.** Two
|
|
68
68
|
hooks were dead for weeks before failures were made visible. If you add a
|
|
69
69
|
hook, log on failure.
|
|
70
|
-
-
|
|
71
|
-
|
|
72
|
-
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
Without this, either path would write memories in the child but never
|
|
91
|
-
clear the parent's queue — the nudge would replay every turn.
|
|
92
|
-
`thatch_extraction_done` is the belt-and-suspenders explicit
|
|
93
|
-
acknowledgment: it drains the buffer without requiring a memory write
|
|
94
|
-
(covers cases where the sub-agent errors out or the host doesn't expose
|
|
95
|
-
parent-child session relationships).
|
|
70
|
+
- **There is no model-facing extraction nudge on opencode.** The old
|
|
71
|
+
`chat.message` extraction nudge (and its `missedNudges` escalation) drove
|
|
72
|
+
a model-driven handshake that raced its own state machine: parent acks
|
|
73
|
+
landed before the child's payload fetch, no-claim completions wiped
|
|
74
|
+
in-flight accepted sets, and mis-targeted session ids made every
|
|
75
|
+
transition a silent no-op - so the same buffered count re-fired forever
|
|
76
|
+
(the September 2026 dispatch-loop report). Extraction is now plugin-driven
|
|
77
|
+
end-to-end: `triggerExtraction` at session idle creates the child and
|
|
78
|
+
interpolates the session ID itself; the child's payload fetch records the
|
|
79
|
+
claim; completions are claim-scoped; the 15-minute stale reaper bounds
|
|
80
|
+
crashed extractors. The nudge survives on MCP hosts only, where no plugin
|
|
81
|
+
can create sessions and the file-backed queue is durable until the
|
|
82
|
+
extractor completes.
|
|
83
|
+
- **A child's `thatch_memory_remember` completes only its claimed delivery**
|
|
84
|
+
via the `childToParent` Map and the claim recorded by its payload fetch.
|
|
85
|
+
A child that never fetched has no claim, and its memory write (or idle
|
|
86
|
+
signal, or ack) consumes nothing - the entries stay held/pending for the
|
|
87
|
+
real extractor. `thatch_extraction_done` is the explicit no-save
|
|
88
|
+
completion: claim-scoped like everything else, so a mis-ordered or
|
|
89
|
+
mis-targeted ack can never drop another extractor's in-flight delivery.
|
|
96
90
|
- **The no-save drain runs in the child-idle handler regardless of writes.**
|
|
97
91
|
When the extraction child goes idle after a no-save run (nothing worth
|
|
98
92
|
extracting), the parent's snapshot entries must still be drained from the
|
package/docs/dev/mcp-parity.md
CHANGED
|
@@ -78,28 +78,27 @@ and the nudge (fallback):
|
|
|
78
78
|
2. When the parent session goes idle (`session.status` idle event) with pending
|
|
79
79
|
buffer entries, the plugin calls `triggerExtraction`: creates a child session
|
|
80
80
|
via `client.session.create` (with `parentID`), then prompts it via
|
|
81
|
-
`client.session.promptAsync` (or `prompt`) with the extraction
|
|
82
|
-
|
|
81
|
+
`client.session.promptAsync` (or `prompt`) with the extraction prompt (the
|
|
82
|
+
parent's session ID is interpolated into the prompt by the plugin). The
|
|
83
|
+
`extracting` set gates re-triggering while the child runs.
|
|
83
84
|
3. The child session runs the `thatch-fact-extractor` skill, writes memories via
|
|
84
|
-
`thatch_memory_remember`.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
4. When the child goes idle, the handler
|
|
88
|
-
|
|
85
|
+
`thatch_memory_remember`. The child's payload fetch records its claim; its
|
|
86
|
+
memory write (or `extraction_done`) completes that claim - consuming only
|
|
87
|
+
the entries it received.
|
|
88
|
+
4. When the child goes idle, the handler finalizes the claim-scoped
|
|
89
|
+
completion, deletes the child session, and fires a toast via
|
|
89
90
|
`client.tui.showToast`.
|
|
90
91
|
|
|
91
|
-
**
|
|
92
|
+
**Failure recovery (no model-facing nudge exists on opencode):**
|
|
92
93
|
|
|
93
94
|
1. If `triggerExtraction` throws (session creation or prompt fails), the catch
|
|
94
|
-
block clears the `extracting` flag.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
(or a child sub-agent via the `childToParent` Map) or `thatch_extraction_done`
|
|
102
|
-
drains the buffer and resets the missed-nudge counter.
|
|
95
|
+
block clears the `extracting` flag. The pending buffer is untouched, and
|
|
96
|
+
the next idle re-triggers extraction - the plugin retries without any
|
|
97
|
+
model cooperation.
|
|
98
|
+
2. If the child errors or is deleted before completing, what it held is
|
|
99
|
+
requeued to pending. If its completion signal never comes at all, the
|
|
100
|
+
15-minute stale reaper returns accepted entries to pending. Either way
|
|
101
|
+
the next idle re-extracts - no interaction is silently lost.
|
|
103
102
|
|
|
104
103
|
Claude Code and Cursor use a **file-backed** queue (`src/extract-queue.ts`)
|
|
105
104
|
because hooks fire one-shot with no cross-call state:
|
|
@@ -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 }` |
|
|
28
|
+
| `chat.message` | `{ sessionID, messageID }` | `{ message, parts }` | Prompt-aware recall nudge (plus prediction/behavior auto-fire). No extraction nudge - extraction is plugin-driven at session idle. Suppressed while compacting 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/cli.md
CHANGED
|
@@ -148,7 +148,8 @@ thatch buffer-batch # append tool batch to queue (Claude Code hook)
|
|
|
148
148
|
thatch buffer-tool # append single tool to queue (Cursor hook)
|
|
149
149
|
thatch flush-tools [--json] # peek queue + print nudges (hook)
|
|
150
150
|
thatch flush-predictions [--json] # prediction-only nudge (hook)
|
|
151
|
-
thatch setup --claude [--cursor] [--global]
|
|
151
|
+
thatch setup --claude [--cursor] [--global] # install config + hooks + skills
|
|
152
|
+
thatch setup --claude --skills-only # refresh just the skill files
|
|
152
153
|
```
|
|
153
154
|
|
|
154
155
|
## Environment variables
|
package/docs/user/setup.md
CHANGED
|
@@ -60,6 +60,16 @@ Skills follow the install scope: project-local installs them to the
|
|
|
60
60
|
repo's `.claude/skills/` so they version with the project; `--global`
|
|
61
61
|
installs them to `~/.claude/skills/` (or `$CLAUDE_CONFIG_DIR/skills/`).
|
|
62
62
|
|
|
63
|
+
To refresh just the skill files without rewriting anything else, add
|
|
64
|
+
`--skills-only`:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
thatch setup --claude --skills-only
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This skips the MCP config, instructions, hooks, and commands, and
|
|
71
|
+
reports the same skills directory and install counts as a full run.
|
|
72
|
+
|
|
63
73
|
`bun` must be on PATH (the thatch binary runs under bun). Setup is
|
|
64
74
|
idempotent. Re-running it updates drifted content without clobbering
|
|
65
75
|
unrelated config, and reports what it did: the skills directory plus
|
|
@@ -84,6 +94,9 @@ thatch setup --cursor --global # user-scoped (~/.cursor/)
|
|
|
84
94
|
Skills follow the install scope: project-local installs them to the
|
|
85
95
|
repo's `.cursor/skills/`; `--global` installs them to `~/.cursor/skills/`.
|
|
86
96
|
|
|
97
|
+
As with Claude Code, `--skills-only` refreshes just the skill files and
|
|
98
|
+
skips the MCP config, instructions, and hooks.
|
|
99
|
+
|
|
87
100
|
Cursor uses a flat hooks format (`{version, hooks:{event:[{command}]}}`)
|
|
88
101
|
and `--json` output for hook commands.
|
|
89
102
|
|
package/docs/user/watchers.md
CHANGED
|
@@ -16,6 +16,12 @@ baseline, then polls the GitHub API through the `gh` CLI once a
|
|
|
16
16
|
minute. When the target changes in a way the watcher cares about, the
|
|
17
17
|
plugin prompts the agent with a notification.
|
|
18
18
|
|
|
19
|
+
Register before you push. CI notifications fire when a check run goes
|
|
20
|
+
from running to completed, so a watcher created after a fast pipeline
|
|
21
|
+
has already finished will never fire for that commit. If every check on
|
|
22
|
+
the head is already done when the watcher is created, the response says
|
|
23
|
+
so and reports the results directly.
|
|
24
|
+
|
|
19
25
|
PR watched events:
|
|
20
26
|
|
|
21
27
|
- `pr_comment` - new top-level comments on the PR
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffober/thatch",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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
|
@@ -212,7 +212,7 @@ export type ChatHostKind = "opencode" | "mcp";
|
|
|
212
212
|
// Display names appear inside wake prompts and chat_list output, so they are
|
|
213
213
|
// capped tight. Bodies are capped so a runaway sender cannot turn a nudge
|
|
214
214
|
// into a context bomb; the reader fetches full content via chat_read anyway.
|
|
215
|
-
const MAX_BODY_LEN = 10_000;
|
|
215
|
+
export const MAX_BODY_LEN = 10_000;
|
|
216
216
|
|
|
217
217
|
// Assigned names are lowercase slugs of a pool name with a numeric counter
|
|
218
218
|
// suffix ("al-go-rithm-00001"), which satisfies NAME_CHARSET. The charset
|
package/src/extract-queue.ts
CHANGED
|
@@ -56,10 +56,25 @@ export function appendBatch(sessionID: string, toolCalls: BatchToolCall[]): void
|
|
|
56
56
|
const additions: ToolInteraction[] = [];
|
|
57
57
|
for (const tc of toolCalls) {
|
|
58
58
|
const lower = tc.tool_name.toLowerCase();
|
|
59
|
-
if (lower === "mcp__thatch__memory_remember"
|
|
59
|
+
if (lower === "mcp__thatch__memory_remember") {
|
|
60
|
+
// The session that writes a memory has processed its queued
|
|
61
|
+
// interactions itself - drain. This is the parent's self-serve path.
|
|
60
62
|
resetMissedCount(sessionID);
|
|
61
63
|
consumeQueue(sessionID);
|
|
62
64
|
existing.length = 0;
|
|
65
|
+
} else if (lower === "mcp__thatch__extraction_done") {
|
|
66
|
+
// An extraction_done ack must NOT drain the queue. When the PARENT
|
|
67
|
+
// acks after dispatching (per the nudge), a drain here deletes the
|
|
68
|
+
// queue before the sub-agent's get_extraction_payload fetch - the
|
|
69
|
+
// accept-before-fetch race, which on this path is a hard delete
|
|
70
|
+
// (silent interaction loss). The queue is consumed instead by the
|
|
71
|
+
// extractor's completion: extraction_done with the parent's
|
|
72
|
+
// session_id (drainExtractionQueue in mcp.ts), or the parent's own
|
|
73
|
+
// memory_remember above. A no-drain ack only resets the escalation
|
|
74
|
+
// counter - the queue persists on disk (durable across
|
|
75
|
+
// interruption), and an extractor that dies without completing
|
|
76
|
+
// leaves it in place for the next nudge to re-dispatch.
|
|
77
|
+
resetMissedCount(sessionID);
|
|
63
78
|
}
|
|
64
79
|
if (lower.startsWith("mcp__thatch__")) continue;
|
|
65
80
|
if (lower === "skill" || lower === "task" || lower === "agent") continue;
|
package/src/extraction.ts
CHANGED
|
@@ -342,9 +342,10 @@ export class ExtractionPipeline {
|
|
|
342
342
|
* Requeues accepted entries that have waited longer than maxAgeMs for a
|
|
343
343
|
* completion signal that never came: the extractor sub-agent crashed
|
|
344
344
|
* without acking, or the completion ack was lost. Without this, accepted
|
|
345
|
-
* entries linger unprocessed
|
|
346
|
-
*
|
|
347
|
-
*
|
|
345
|
+
* entries linger unprocessed with nothing to re-extract them - silent
|
|
346
|
+
* loss. Returns the session ids that had entries requeued; on opencode
|
|
347
|
+
* the idle trigger re-extracts them, on MCP hosts the next flush-tools
|
|
348
|
+
* nudge picks them up.
|
|
348
349
|
*/
|
|
349
350
|
requeueStaleAccepted(maxAgeMs = ACCEPTED_STALE_MS): string[] {
|
|
350
351
|
const stale = [...this.#acceptedAt.entries()]
|
package/src/opencode/v2.ts
CHANGED
|
@@ -529,8 +529,9 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
|
|
|
529
529
|
// returns the created SessionInfo directly.
|
|
530
530
|
const result = await session.create({ location: { directory: worktree }, title: input.title });
|
|
531
531
|
const id = result?.id;
|
|
532
|
-
// A shape mismatch must fail
|
|
533
|
-
//
|
|
532
|
+
// A shape mismatch must fail loudly: the runtime logs it and retries
|
|
533
|
+
// extraction at the next idle. It must not silently corrupt the
|
|
534
|
+
// child-session maps.
|
|
534
535
|
if (!id) throw new Error(`v2 session.create returned no id: ${JSON.stringify(result)?.slice(0, 200)}`);
|
|
535
536
|
// Register for event forwarding: the child's events carry the project
|
|
536
537
|
// directory, which the pump's directory filter would otherwise drop.
|
|
@@ -539,7 +540,7 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
|
|
|
539
540
|
},
|
|
540
541
|
// No delete on the v2 SessionDomain: extraction child sessions are not
|
|
541
542
|
// cleaned up on v2 (documented gap; the bookkeeping maps still keep the
|
|
542
|
-
//
|
|
543
|
+
// claim/completion lifecycle consistent). Two user-visible consequences: the session
|
|
543
544
|
// picker accumulates one thatch-extraction entry per extraction, and
|
|
544
545
|
// "continue last session" (-c) logic that picks the newest top-level
|
|
545
546
|
// session will land in an extraction child after any session that
|