@jeffober/thatch 0.1.28 → 0.1.30

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.
Files changed (61) hide show
  1. package/README.md +24 -10
  2. package/artifacts/skills/thatch-code-review.md +9 -7
  3. package/artifacts/skills/thatch-review-economy.md +97 -0
  4. package/artifacts/skills/thatch-review-followup.md +1 -1
  5. package/artifacts/skills/thatch-review-synthesizer.md +15 -5
  6. package/bin/qa-run +38 -0
  7. package/bin/release +21 -0
  8. package/bin/thatch +17 -5
  9. package/docs/dev/README.md +71 -18
  10. package/docs/dev/features/README.md +33 -0
  11. package/docs/dev/features/behavior-engine.md +271 -0
  12. package/docs/dev/features/cicd.md +89 -0
  13. package/docs/dev/features/cli.md +81 -0
  14. package/docs/dev/features/compaction-recovery.md +48 -0
  15. package/docs/dev/features/database.md +244 -0
  16. package/docs/dev/features/deduplication.md +102 -0
  17. package/docs/dev/features/extraction.md +253 -0
  18. package/docs/dev/features/hygiene.md +81 -0
  19. package/docs/dev/features/memory-store.md +154 -0
  20. package/docs/dev/features/multi-host.md +228 -0
  21. package/docs/dev/features/nudge-pipeline.md +282 -0
  22. package/docs/dev/features/prediction-engine.md +253 -0
  23. package/docs/dev/features/qa-system.md +205 -0
  24. package/docs/dev/features/session-lifecycle.md +110 -0
  25. package/docs/dev/features/setup.md +199 -0
  26. package/docs/dev/features/sideband.md +104 -0
  27. package/docs/dev/gotchas.md +3 -3
  28. package/docs/dev/mcp-parity.md +6 -4
  29. package/docs/dev/setup-and-hooks.md +4 -4
  30. package/docs/dev/skills.md +10 -8
  31. package/docs/plans/README.md +2 -2
  32. package/docs/user/README.md +58 -20
  33. package/docs/user/behavior-engine.md +85 -0
  34. package/docs/user/cli.md +98 -0
  35. package/docs/user/code-review.md +73 -0
  36. package/docs/user/deduplication.md +70 -0
  37. package/docs/user/default-behaviors.md +75 -0
  38. package/docs/user/extraction.md +74 -0
  39. package/docs/user/hygiene.md +76 -0
  40. package/docs/user/memory.md +81 -0
  41. package/docs/user/prediction-engine.md +87 -0
  42. package/docs/user/setup.md +110 -0
  43. package/docs/user/skills.md +97 -0
  44. package/package.json +2 -2
  45. package/src/behavior.ts +120 -0
  46. package/src/db.ts +160 -417
  47. package/src/embeddings.ts +0 -41
  48. package/src/extraction.ts +2 -2
  49. package/src/git.ts +3 -1
  50. package/src/index.ts +55 -9
  51. package/src/mcp.ts +30 -1
  52. package/src/prediction.ts +127 -0
  53. package/src/prompts.ts +135 -188
  54. package/src/scoring-engine.ts +374 -0
  55. package/src/seed-behaviors.ts +339 -0
  56. package/src/setup.ts +2 -6
  57. package/src/sideband.ts +36 -8
  58. package/src/skills.ts +1 -0
  59. package/src/tool-defs.ts +172 -25
  60. package/src/vector-math.ts +30 -0
  61. package/docs/qa/README.md +0 -175
@@ -0,0 +1,244 @@
1
+ # Database
2
+
3
+ Thatch stores all data in a single SQLite file. The schema has 11 tables organized around three concerns: memory entries, the prediction engine, and the behavior engine.
4
+
5
+ ## Configuration
6
+
7
+ - Default path: `$XDG_CONFIG_HOME/thatch/thatch.db` (override via `THATCH_DB_PATH`)
8
+ - PRAGMAs: `journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON`
9
+ - Single file, no external services
10
+
11
+ ## Schema overview
12
+
13
+ 11 tables in three groups:
14
+
15
+ | Group | Tables | Purpose |
16
+ |-------|--------|---------|
17
+ | Memory | `stores`, `entries`, `dedup_pairs` | Core memory CRUD + dedup verdicts |
18
+ | Prediction engine | `prediction_matchers`, `predictions`, `prediction_edges`, `prediction_provenance` | User decision model |
19
+ | Behavior engine | `behavior_matchers`, `behaviors`, `behavior_edges`, `behavior_provenance` | LLM self-discipline rules |
20
+
21
+ The prediction and behavior engines share the same four-table shape (matchers, items, edges, provenance) with different table names. They are separate because the semantics differ: predictions model what the user wants; behaviors model what the LLM should do.
22
+
23
+ ## Memory tables
24
+
25
+ ### stores
26
+
27
+ Registry of all store names. The `global` store is inserted automatically at schema init.
28
+
29
+ ```sql
30
+ stores(name TEXT PRIMARY KEY)
31
+ ```
32
+
33
+ ### entries
34
+
35
+ The core table. Memory entries with embeddings, metadata, and usage telemetry.
36
+
37
+ ```sql
38
+ entries(
39
+ slug TEXT NOT NULL,
40
+ store TEXT NOT NULL REFERENCES stores(name),
41
+ label TEXT NOT NULL,
42
+ content TEXT NOT NULL,
43
+ embedding BLOB,
44
+ model TEXT,
45
+ branch TEXT,
46
+ confidence INTEGER,
47
+ archived INTEGER NOT NULL DEFAULT 0,
48
+ created_at TEXT NOT NULL,
49
+ updated_at TEXT NOT NULL,
50
+ recall_count INTEGER NOT NULL DEFAULT 0,
51
+ last_recalled_at TEXT,
52
+ PRIMARY KEY (slug, store)
53
+ )
54
+ ```
55
+
56
+ - `slug`: derived from label via `slugify()`. Unicode letters/digits preserved. All-symbol labels fall back to hash.
57
+ - `embedding`: raw Float32Array bytes. Null if not yet embedded.
58
+ - `model`: embedding model tag (informational; dimension is the real discriminator).
59
+ - `branch`: git branch scope. Null = project-wide.
60
+ - `confidence`: 1-10 scale. Null = unset.
61
+ - `archived`: 0 = live, 1 = archived. Search, dedup, and staleness queries all exclude archived by default.
62
+ - `recall_count` / `last_recalled_at`: usage telemetry, stamped by `db.recall()` (not `db.search()`).
63
+
64
+ ### dedup_pairs
65
+
66
+ Records which duplicate-candidate pairs have been reviewed.
67
+
68
+ ```sql
69
+ dedup_pairs(
70
+ store TEXT NOT NULL,
71
+ slug_a TEXT NOT NULL,
72
+ slug_b TEXT NOT NULL,
73
+ status TEXT NOT NULL,
74
+ checked_at TEXT NOT NULL,
75
+ PRIMARY KEY (store, slug_a, slug_b)
76
+ )
77
+ ```
78
+
79
+ - Slugs stored sorted (a < b) for canonical matching.
80
+ - `status`: "duplicate", "supplement", "contradiction", or "unrelated".
81
+ - Overwriting or forgetting either memory clears the verdict (DELETE in `remember()` and `forgetEntry()`).
82
+
83
+ ## Prediction engine tables
84
+
85
+ ### prediction_matchers
86
+
87
+ Situation descriptions that trigger predictions. Embedded, cosine-matched at `chat.message`.
88
+
89
+ ```sql
90
+ prediction_matchers(
91
+ id TEXT PRIMARY KEY,
92
+ store TEXT NOT NULL REFERENCES stores(name),
93
+ description TEXT NOT NULL,
94
+ embedding BLOB,
95
+ model TEXT,
96
+ created_at TEXT NOT NULL,
97
+ updated_at TEXT NOT NULL
98
+ )
99
+ ```
100
+
101
+ ### predictions
102
+
103
+ User preference predictions with Bayesian confidence.
104
+
105
+ ```sql
106
+ predictions(
107
+ id TEXT PRIMARY KEY,
108
+ store TEXT NOT NULL REFERENCES stores(name),
109
+ statement TEXT NOT NULL,
110
+ rationale TEXT,
111
+ embedding BLOB,
112
+ model TEXT,
113
+ confidence REAL NOT NULL DEFAULT 0.5,
114
+ confirm_count REAL NOT NULL DEFAULT 0,
115
+ disconfirm_count REAL NOT NULL DEFAULT 0,
116
+ created_at TEXT NOT NULL,
117
+ updated_at TEXT NOT NULL
118
+ )
119
+ ```
120
+
121
+ - `confidence`: Bayesian posterior. `p = (confirm + K*P0) / (confirm + disconfirm + K)`, K=5, P0=0.5.
122
+ - `confirm_count` / `disconfirm_count`: REAL (fractional for soft signals, weight 0.25).
123
+
124
+ ### prediction_edges
125
+
126
+ Many-to-many links between matchers and predictions.
127
+
128
+ ```sql
129
+ prediction_edges(
130
+ matcher_id TEXT NOT NULL REFERENCES prediction_matchers(id) ON DELETE CASCADE,
131
+ prediction_id TEXT NOT NULL REFERENCES predictions(id) ON DELETE CASCADE,
132
+ weight REAL NOT NULL DEFAULT 1.0,
133
+ created_at TEXT NOT NULL,
134
+ PRIMARY KEY (matcher_id, prediction_id)
135
+ )
136
+ ```
137
+
138
+ ### prediction_provenance
139
+
140
+ Audit trail of every signal applied to a prediction.
141
+
142
+ ```sql
143
+ prediction_provenance(
144
+ id TEXT PRIMARY KEY,
145
+ prediction_id TEXT NOT NULL REFERENCES predictions(id) ON DELETE CASCADE,
146
+ signal TEXT NOT NULL,
147
+ detail TEXT,
148
+ created_at TEXT NOT NULL
149
+ )
150
+ ```
151
+
152
+ - `signal`: "confirm", "disconfirm", "soft", or "create".
153
+
154
+ ## Behavior engine tables
155
+
156
+ Same shape as prediction engine, with behavior-specific semantics.
157
+
158
+ ### behavior_matchers
159
+
160
+ ```sql
161
+ behavior_matchers(
162
+ id TEXT PRIMARY KEY,
163
+ store TEXT NOT NULL REFERENCES stores(name),
164
+ description TEXT NOT NULL,
165
+ embedding BLOB,
166
+ model TEXT,
167
+ created_at TEXT NOT NULL,
168
+ updated_at TEXT NOT NULL
169
+ )
170
+ ```
171
+
172
+ ### behaviors
173
+
174
+ ```sql
175
+ behaviors(
176
+ id TEXT PRIMARY KEY,
177
+ store TEXT NOT NULL REFERENCES stores(name),
178
+ statement TEXT NOT NULL,
179
+ rationale TEXT,
180
+ embedding BLOB,
181
+ model TEXT,
182
+ confidence REAL NOT NULL DEFAULT 0.5,
183
+ confirm_count REAL NOT NULL DEFAULT 0,
184
+ disconfirm_count REAL NOT NULL DEFAULT 0,
185
+ created_at TEXT NOT NULL,
186
+ updated_at TEXT NOT NULL
187
+ )
188
+ ```
189
+
190
+ ### behavior_edges
191
+
192
+ ```sql
193
+ behavior_edges(
194
+ matcher_id TEXT NOT NULL REFERENCES behavior_matchers(id) ON DELETE CASCADE,
195
+ behavior_id TEXT NOT NULL REFERENCES behaviors(id) ON DELETE CASCADE,
196
+ weight REAL NOT NULL DEFAULT 1.0,
197
+ created_at TEXT NOT NULL,
198
+ PRIMARY KEY (matcher_id, behavior_id)
199
+ )
200
+ ```
201
+
202
+ ### behavior_provenance
203
+
204
+ ```sql
205
+ behavior_provenance(
206
+ id TEXT PRIMARY KEY,
207
+ behavior_id TEXT NOT NULL REFERENCES behaviors(id) ON DELETE CASCADE,
208
+ signal TEXT NOT NULL,
209
+ detail TEXT,
210
+ created_at TEXT NOT NULL
211
+ )
212
+ ```
213
+
214
+ - `signal`: "confirm" (ham), "disconfirm" (spam), or "codify".
215
+
216
+ ## Schema migration
217
+
218
+ `recall_count`, `last_recalled_at`, and `archived` are added to pre-existing databases by an idempotent column migration at init. The migration uses `PRAGMA table_info` to check for the column's existence, then `ALTER TABLE ADD COLUMN` if missing. This handles databases created before these columns existed.
219
+
220
+ ## Embedding serialization
221
+
222
+ Embeddings are raw Float32Array bytes stored as BLOBs. Serialization honors `byteOffset`/`byteLength` — transformers.js can return views into larger tensor buffers, and serializing the whole backing buffer corrupts vectors. Always serialize the view's own bytes, not the underlying buffer.
223
+
224
+ ## Key invariants
225
+
226
+ - Single SQLite file, WAL mode, 5s busy timeout, foreign keys ON.
227
+ - Embedding spaces are discriminated by vector dimension, not model tag.
228
+ - Embedding serialization honors `byteOffset`/`byteLength` (views into larger buffers).
229
+ - Slugs are slugified labels; slug + store = composite PK.
230
+ - Dedup verdicts auto-clear when either memory is overwritten or forgotten.
231
+ - Archived memories excluded from search, dedup, and staleness by default.
232
+ - Column migration is idempotent (`PRAGMA table_info` + `ALTER TABLE`).
233
+
234
+ ## Interactions with other features
235
+
236
+ - Memory store ([memory-store.md](memory-store.md)): `entries` table is the core; `stores` table is the registry
237
+ - Deduplication ([deduplication.md](deduplication.md)): `dedup_pairs` table tracks reviewed pairs
238
+ - Prediction engine ([prediction-engine.md](prediction-engine.md)): 4 prediction tables
239
+ - Behavior engine ([behavior-engine.md](behavior-engine.md)): 4 behavior tables
240
+ - Hygiene ([hygiene.md](hygiene.md)): `staleEntryCount` uses `recall_count` and `last_recalled_at`; `branchesInStore` uses `branch` column
241
+
242
+ ## Source files
243
+
244
+ - `src/db.ts` — all schema, CRUD, search, scoring, migrations
@@ -0,0 +1,102 @@
1
+ # Deduplication System
2
+
3
+ Agent-driven detection and resolution of duplicate memories. The system
4
+ surfaces candidates; the agent decides what to do.
5
+
6
+ ## What it does
7
+
8
+ - `find_duplicates` tool: cosine similarity pairs, grouped into
9
+ connected-component clusters
10
+ - `dedup_mark_checked` tool: records a pairwise verdict so `find_duplicates`
11
+ stops re-reporting
12
+ - Automatic verdict clearing: overwriting either memory clears the verdict
13
+ - `thatch-dedup-classifier` skill: classifies the relationship and reconciles
14
+ pairs
15
+
16
+ ## How it works
17
+
18
+ ### find_duplicates (tool)
19
+
20
+ - Parameters: `store` (optional, default project store), `threshold`
21
+ (optional, default 0.85)
22
+ - Finds memory pairs whose embeddings exceed the cosine similarity threshold
23
+ - Groups related pairs into connected-component clusters: a cluster of 3+
24
+ labels usually means one topic fragmented across entries that should be
25
+ consolidated
26
+ - Pairs already resolved via `dedup_mark_checked` are skipped
27
+ - Output: clustered rendering — "Cluster of N:" then
28
+ `[score:N] labelA <-> labelB` lines
29
+ - Read-only
30
+
31
+ ### dedup_mark_checked (tool)
32
+
33
+ - Parameters: `label_a`, `label_b` (both required), `status` (one of
34
+ `duplicate`, `supplement`, `contradiction`, `unrelated`), `store` (optional)
35
+ - Records a pairwise verdict so `find_duplicates` stops re-reporting that pair
36
+ - Labels are slugified internally
37
+ - Overwriting either memory later clears the verdict automatically
38
+ (DB-level: the `remember()` and `forgetEntry()` functions `DELETE` from
39
+ `dedup_pairs` for the affected slug)
40
+ - Returns `[checked] store :: a <-> b -> status`
41
+
42
+ ### Verdict lifecycle
43
+
44
+ 1. `find_duplicates` surfaces a pair above threshold
45
+ 2. Agent loads the `thatch-dedup-classifier` skill
46
+ 3. Agent classifies the pair: `duplicate` (merge), `supplement` (keep both),
47
+ `contradiction` (resolve), `unrelated` (mark and ignore)
48
+ 4. For duplicates: agent merges via `memory_remember(overwrite: true)` +
49
+ `memory_forget` the other entry
50
+ 5. Agent calls `dedup_mark_checked` for the surviving pair
51
+ 6. Overwriting either memory clears the verdict → the pair can re-flag if the
52
+ merge created a new similarity
53
+
54
+ ### The dedup_pairs table
55
+
56
+ - PK: `(store, slug_a, slug_b)` — slugs stored sorted (`a < b`) for canonical
57
+ matching
58
+ - No FK to entries (slugs are compared by string, not by FK constraint)
59
+ - Overwriting or forgetting an entry clears all verdicts involving that slug
60
+ (`DELETE` in `remember()` and `forgetEntry()`)
61
+
62
+ ### thatch-dedup-classifier skill
63
+
64
+ - Shared skill (`artifacts/skills/thatch-dedup-classifier.md`)
65
+ - Classifies the relationship between two similar memory entries
66
+ - Provides a structured process: read both entries, compare content, decide
67
+ verdict, reconcile
68
+ - Can be dispatched as a sub-agent by the extraction pipeline or loaded
69
+ directly by the agent
70
+
71
+ ## Interactions with other features
72
+
73
+ - [Memory store](memory-store.md): `find_duplicates` searches stored memories;
74
+ merging uses `memory_remember(overwrite)` + `memory_forget`.
75
+ - [Hygiene](hygiene.md): hygiene report counts pending dedup pairs (same
76
+ underlying `db.findDuplicates`).
77
+ - Skills: `thatch-dedup-classifier` is the skill that guides the
78
+ reconciliation process.
79
+ - [Extraction](extraction.md): extraction can trigger the dedup cycle when
80
+ write-time duplicate detection fires.
81
+
82
+ ## Source files
83
+
84
+ - `src/db.ts` — `findDuplicates`, `markChecked`, `dedup_pairs` table
85
+ - `src/tool-defs.ts` — 2 dedup tools (`find_duplicates`, `dedup_mark_checked`)
86
+ - `artifacts/skills/thatch-dedup-classifier.md` — classifier skill
87
+
88
+ ## Database tables
89
+
90
+ - `dedup_pairs(store, slug_a, slug_b, status, checked_at, PK(store, slug_a, slug_b))`
91
+
92
+ ## Key invariants
93
+
94
+ - `find_duplicates` groups pairs into clusters (connected components). A
95
+ cluster of 3+ should be consolidated into one memory.
96
+ - Verdicts are automatically cleared when either memory is overwritten or
97
+ forgotten. This is intended — a merge can create a new similarity that
98
+ should re-flag.
99
+ - The threshold (0.85) is higher than the recall nudge threshold (0.55) because
100
+ "duplicate" is a stronger claim than "relates to".
101
+ - Slugs in `dedup_pairs` are stored sorted (`a < b`) for canonical matching
102
+ regardless of argument order.
@@ -0,0 +1,253 @@
1
+ # Extraction Pipeline
2
+
3
+ Automated capture of durable facts from tool interactions. The agent does not
4
+ need to remember to save — the system buffers tool calls and nudges the agent
5
+ to extract memories from them.
6
+
7
+ The facts most worth remembering overwhelmingly surface in tool call results
8
+ (file contents, command output, API responses, git state) rather than in
9
+ conversation prose. So tool calls serve as both the **trigger** and the
10
+ **queue**. A fact that exists only in conversation never enters the buffer;
11
+ the system prompt instructs the agent to call `thatch_memory_remember`
12
+ directly for conversation-derived knowledge.
13
+
14
+ ## What it does
15
+
16
+ - Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call for later
17
+ extraction
18
+ - Two paths: **direct extraction** via a child session (opencode, primary) and
19
+ **nudge-based extraction** (all hosts, fallback for opencode, primary for
20
+ MCP)
21
+ - The extraction nudge escalates: polite (0–1 missed), insistent (2),
22
+ all-caps shouting (3+)
23
+ - `thatch_get_extraction_payload` fetches queued interactions as JSON,
24
+ keeping the full payload out of the main session's context window
25
+ - `thatch_extraction_done` acknowledges and quiets the nudge
26
+ - AMQP-style buffer lifecycle for opencode: pending → accepted → completed,
27
+ with requeue on failure
28
+ - File-backed JSONL queue for MCP hosts (no cross-call state)
29
+ - Parent-child session drain: the child drains the parent's snapshot,
30
+ preserving interleaved-turn entries added after the snapshot was taken
31
+
32
+ ## How it works
33
+
34
+ ### Tool interaction buffering
35
+
36
+ After every tool execution, non-`thatch_*`, non-`skill`, non-`task` tool
37
+ calls are buffered for later extraction.
38
+
39
+ **opencode** — `tool.execute.after` hook in `src/index.ts`:
40
+
41
+ - In-memory ring buffer per session (`ExtractionPipeline` in
42
+ `src/extraction.ts`)
43
+ - Max 20 interactions per session
44
+ - For child sessions, also tracks new/updated/deleted metrics via
45
+ `childMetrics`
46
+ - `tool.execute.after` is a **plugin hook, not a bus event**. Moving it into
47
+ the `event` handler silently never fires it — the event bus has no such
48
+ event
49
+ - Filtering rationale: `thatch_*` tools would echo the store back into
50
+ itself; `skill`/`task` meta-tools would create a feedback loop (extraction
51
+ triggers a skill load, which gets buffered, which triggers another
52
+ extraction)
53
+
54
+ **MCP hosts** — `bin/thatch`, `src/extract-queue.ts`:
55
+
56
+ - Claude Code: `PostToolBatch` hook → `thatch buffer-batch` (reads JSON from
57
+ stdin: `{ session_id, tool_calls }`)
58
+ - Cursor: `postToolUse` hook → `thatch buffer-tool` (reads JSON from stdin,
59
+ single tool, uses `conversation_id`)
60
+ - File-backed JSONL queue under
61
+ `$XDG_CACHE_HOME/thatch/queue/<session>.jsonl` (max 20, oldest dropped)
62
+ - Silent on success (no stdout) so the agent loop is not delayed
63
+ - Filters the same tools as opencode (`mcp__thatch__memory_remember`,
64
+ `mcp__thatch__extraction_done`, `mcp__thatch__*`, `skill`, `task`, `agent`)
65
+
66
+ ### Direct extraction (opencode, primary path)
67
+
68
+ When a parent session goes idle (`session.status` idle event) with pending
69
+ buffer interactions:
70
+
71
+ 1. `triggerExtraction` adds the parent ID to the `extracting` set — this
72
+ suppresses the nudge in `chat.message`
73
+ 2. Peeks the buffer to count pending interactions
74
+ 3. Creates a child session via
75
+ `client.session.create({ parentID, title: "thatch-extraction" })`
76
+ 4. The `session.created` event fires, populating `childToParent` and
77
+ `parentSnapshots` (a snapshot of the full pending buffer at dispatch time)
78
+ 5. Adds the child ID to the `extractionChildren` set
79
+ 6. Prompts the child with `extractionDirectPrompt(count, sessionID)`
80
+ 7. If background sub-agents are enabled
81
+ (`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`): `promptAsync`. Otherwise:
82
+ fire-and-forget `prompt`
83
+ 8. On prompt failure: `cleanupChild` removes the child from all maps and
84
+ deletes the child session
85
+
86
+ The child session then:
87
+
88
+ - Calls `thatch_get_extraction_payload` to fetch the queued interactions
89
+ - Runs the [thatch-fact-extractor](../skills.md) skill
90
+ - Writes memories via `thatch_memory_remember`
91
+ - Goes idle
92
+
93
+ On child idle (`session.status` idle event, extraction child):
94
+
95
+ 1. Drains the parent's snapshot from the pending buffer via
96
+ `consumeSnapshot` — removes only entries captured at dispatch time by
97
+ reference identity, preserving interleaved-turn entries
98
+ 2. Fires a toast with extraction metrics (only if memories were actually
99
+ written)
100
+ 3. `completeAccepted(parentID)`, resets `missedNudges`
101
+ 4. Cleans up all maps
102
+ 5. `consume(childID)` — drains the child's own buffer
103
+ 6. Deletes the child session
104
+
105
+ ### Nudge-based extraction (fallback for opencode, primary for MCP)
106
+
107
+ If direct extraction was never triggered or threw an error (session not in
108
+ the `extracting` set) **and** the buffer has pending interactions:
109
+
110
+ - The next user message (`chat.message` for opencode,
111
+ `UserPromptSubmit`/`beforeSubmitPrompt` for MCP) gets an extraction nudge
112
+ - The nudge carries the session ID and fetch tool name — not the full
113
+ payload — so the sub-agent calls `thatch_get_extraction_payload` to
114
+ retrieve the interactions as a tool response
115
+
116
+ **Nudge escalation** (via the `missedNudges` counter):
117
+
118
+ | Missed count | Tone |
119
+ |--------------|------|
120
+ | 0–1 | Polite |
121
+ | 2 | Insistent |
122
+ | 3+ | All-caps shouting |
123
+
124
+ The buffer persists until the agent writes a memory or calls
125
+ `thatch_extraction_done`. Ignored nudges repeat and escalate.
126
+
127
+ - **opencode**: in-memory `missedNudges` map
128
+ - **MCP hosts**: file-backed `.count` file per session
129
+
130
+ ### Buffer lifecycle (opencode, AMQP-style)
131
+
132
+ Buffered interactions move through four states in `ExtractionPipeline`
133
+ (`src/extraction.ts`):
134
+
135
+ - **pending** — interactions in the ring buffer. The nudge fires on
136
+ `chat.message`.
137
+ - **accepted** — moved from pending by `accept()`. The nudge quiets, but
138
+ entries are not dropped yet.
139
+ - **completed** — dropped by `completeAccepted()`. Triggered by a
140
+ `memory_remember` or `extraction_done` call in the child, or the child
141
+ going idle.
142
+ - **requeued** — moved back to pending by `requeueAccepted()`. Triggered by
143
+ the child session erroring or being deleted before completing.
144
+
145
+ Key methods:
146
+
147
+ | Method | Action |
148
+ |--------|--------|
149
+ | `push()` | Add interaction to pending buffer (capped at 20) |
150
+ | `peek()` | Read without clearing |
151
+ | `consume()` | Delete the session's pending buffer (called on memory write) |
152
+ | `accept()` | Move pending to accepted — quiet the nudge, hold entries |
153
+ | `completeAccepted()` | Drop accepted entries — extractor finished |
154
+ | `requeueAccepted()` | Move accepted back to pending — extractor died |
155
+ | `consumeSnapshot()` | Remove only entries that were in the snapshot (by reference identity). Used for child-parent drain. |
156
+
157
+ ### Parent-child session drain
158
+
159
+ When a child session writes a memory via `thatch_memory_remember`:
160
+
161
+ 1. `tool.execute.after` detects the memory write
162
+ 2. If in a child session (`childToParent.has(sessionID)`): track metrics,
163
+ `completeAccepted(parentID)`, drain the parent's snapshot via
164
+ `consumeSnapshot`, reset the parent's `missedNudges`
165
+ 3. `consume(sessionID)` — drains the child's own buffer
166
+
167
+ `consumeSnapshot` is **snapshot-aware**: it removes only entries that were
168
+ in the parent's buffer at dispatch time (by reference identity).
169
+ Interleaved-turn entries — added after the snapshot was taken, while the
170
+ child was extracting — survive. This prevents data loss when the parent
171
+ continues working while the child extracts.
172
+
173
+ ### MCP file-backed queue drain
174
+
175
+ - `drainExtractionQueue(sessionID)` calls `resetMissedCount` +
176
+ `consumeQueue` (deletes the JSONL file)
177
+ - Triggered by: `thatch_extraction_done` called with the parent's
178
+ `session_id`, or `thatch_memory_remember` called
179
+ - `appendBatch` in `extract-queue.ts` self-detects
180
+ `memory_remember`/`extraction_done` calls and resets the counter +
181
+ consumes the queue inline
182
+
183
+ ### The `extraction_done` tool
184
+
185
+ - No-op confirmation (`[acknowledged]`) unless `session_id` is passed and
186
+ `drainExtractionQueue` is wired
187
+ - On the MCP path with `session_id`: resets the missed-nudge count +
188
+ consumes (deletes) the file-backed queue
189
+ - The real state transitions happen in the host's post-tool hook
190
+ (`tool.execute.after` for opencode, `PostToolBatch`/`appendBatch` for MCP)
191
+ - The tool exists primarily so the model has a recognizable name to key on
192
+
193
+ ### The `get_extraction_payload` tool
194
+
195
+ - Fetches the queued tool interactions for extraction as serialized JSON
196
+ (`interactions`, `projectStore`, `globalStore`)
197
+ - **opencode**: peeks accepted + pending interactions, builds JSON via
198
+ `buildExtractionPayload`
199
+ - **MCP**: peeks the file-backed queue, builds the same JSON payload
200
+ - Returns `null` when no interactions are queued
201
+ - Read-only — it peeks the queue; it does not consume it. Consumption is
202
+ `extraction_done`'s job
203
+
204
+ ### Background sub-agent support (opencode)
205
+
206
+ - Experimental flag: `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` (set in
207
+ `mise.toml`)
208
+ - When enabled: `promptAsync` for child session creation (async extraction)
209
+ - When disabled: fire-and-forget `prompt` (synchronous but unawaited)
210
+
211
+ ## Interactions with other features
212
+
213
+ - [Memory store](memory-store.md) — extraction writes memories via
214
+ `thatch_memory_remember`
215
+ - [Nudge pipeline](nudge-pipeline.md) — the extraction nudge is tier 1
216
+ (highest priority, returns early)
217
+ - [Session lifecycle](session-lifecycle.md) — direct extraction is triggered
218
+ by `session.status` idle; child lifecycle is managed by the event handler
219
+ - [Skills](../skills.md) — the thatch-fact-extractor skill is dispatched to
220
+ child sessions
221
+ - [Multi-host](multi-host.md) — in-memory ring buffer for opencode,
222
+ file-backed queue for MCP hosts
223
+ - [Compaction recovery](compaction-recovery.md) — extraction nudge is
224
+ suppressed during compaction (tools are blocked)
225
+
226
+ ## Source files
227
+
228
+ | File | Responsibility |
229
+ |------|----------------|
230
+ | `src/extraction.ts` | In-memory ring buffer (`ExtractionPipeline`), shared payload builders (`buildExtractionPayload`, `deriveTitle`, `summarizeArgs`) |
231
+ | `src/extract-queue.ts` | File-backed JSONL queue for MCP hosts |
232
+ | `src/index.ts` | opencode hooks: `tool.execute.after`, `session.status` idle, `session.created`, `session.error`, `session.deleted`, `chat.message` (nudge tier 1) |
233
+ | `bin/thatch` | `buffer-batch`, `buffer-tool`, `flush-tools` subcommands |
234
+ | `src/prompts.ts` | `extractionNudge` (with escalation), `extractionDirectPrompt` |
235
+
236
+ ## Key invariants
237
+
238
+ 1. **Tool filtering is absolute.** `thatch_*`, `skill`, and `task` tools are
239
+ never buffered. Buffering them would echo the store into itself or create
240
+ a feedback loop.
241
+ 2. **The nudge peeks, never flushes.** The buffer persists until a memory
242
+ write or `extraction_done`. Ignored nudges repeat and escalate.
243
+ 3. **`consumeSnapshot` is snapshot-aware.** It removes only entries captured
244
+ at dispatch time (by reference identity), preserving interleaved-turn
245
+ entries added while the child was extracting.
246
+ 4. **`tool.execute.after` is a plugin hook, not a bus event.** The event bus
247
+ has no such event. Moving the buffering logic into the `event` handler
248
+ silently never fires it.
249
+ 5. **MCP host hooks must be silent.** `PostToolBatch`/`postToolUse` produce
250
+ no stdout — only `flush-tools` prints. Any stdout delays the agent loop.
251
+ 6. **The no-save drain runs unconditionally.** The child-idle handler drains
252
+ the remaining snapshot regardless of whether the child wrote memories,
253
+ covering no-save extraction runs.
@@ -0,0 +1,81 @@
1
+ # Hygiene System
2
+
3
+ Store maintenance signals surfaced at session start. The plugin never deletes
4
+ memories itself — it surfaces problems and lets the agent tend the store.
5
+
6
+ ## What it does
7
+
8
+ - Three signals: duplicate candidates, stale entries, orphaned branch memories
9
+ - Runs at session start on all three hosts
10
+ - Only non-zero signals are reported (healthy stores stay silent)
11
+ - Standalone `thatch hygiene` CLI command for manual inspection
12
+
13
+ ## How it works
14
+
15
+ ### Three signals
16
+
17
+ 1. **Duplicate candidates** — `db.findDuplicates(repo)`: near-duplicate memory
18
+ pairs via cosine >= 0.85 pending review. These are the same pairs that
19
+ `find_duplicates` surfaces, but the hygiene report is a count-only summary,
20
+ not the full pair list.
21
+
22
+ 2. **Stale entries** — `db.staleEntryCount(repo, cutoff)`: memories neither
23
+ updated nor recalled in 90+ days (`STALE_DAYS = 90`). Uses `recall_count`
24
+ and `last_recalled_at` columns. Archived memories are excluded
25
+ (`WHERE archived = 0`).
26
+
27
+ 3. **Orphaned branch memories** — `db.branchesInStore(repo)` cross-references
28
+ against `git.listBranches(worktree)`. Memories scoped to branches that no
29
+ longer exist. Skipped when the worktree is not a git repo
30
+ (`listBranches` returns `[]`) to avoid false positives — without a git
31
+ repo, every branch-scoped memory would look orphaned.
32
+
33
+ ### When it runs
34
+
35
+ - **opencode**: `session.created` event for top-level sessions (no `parentID`).
36
+ The hygiene report is appended to the session-start reminder sent via
37
+ `client.session.prompt`.
38
+ - **Claude Code**: `SessionStart` hook → `thatch reminder` (runs
39
+ `hygieneReport`, appends to reminder text, prints to stdout).
40
+ - **Cursor**: `sessionStart` hook → `thatch reminder --json` (same, wrapped as
41
+ `additional_context` JSON).
42
+ - **Standalone**: `thatch hygiene` CLI command (prints report or
43
+ "Store is healthy." to stdout).
44
+
45
+ ### Report format
46
+
47
+ Only non-zero signals are reported. A healthy store produces no output
48
+ (silent). The report is appended to the session-start reminder with
49
+ instructions for the agent to tend the store when convenient.
50
+
51
+ ### Shared implementation
52
+
53
+ `hygieneReport(db, repo, worktree)` in `src/hygiene.ts` is shared by:
54
+
55
+ - The opencode plugin's session-start hook (`src/index.ts`)
56
+ - The CLI's `thatch reminder` and `thatch hygiene` subcommands (`bin/thatch`)
57
+
58
+ ## Interactions with other features
59
+
60
+ - [Deduplication](deduplication.md): hygiene reports the count of pending dedup
61
+ pairs; the agent uses `find_duplicates` to see the actual pairs.
62
+ - [Memory store](memory-store.md): staleness uses `recall_count` and
63
+ `last_recalled_at`; orphan check uses the `branch` column.
64
+ - Session lifecycle: hygiene runs at `session.created` for top-level sessions.
65
+ - Nudge pipeline: hygiene is part of the session-start reminder, not the
66
+ per-message nudge pipeline.
67
+
68
+ ## Source files
69
+
70
+ - `src/hygiene.ts` — `hygieneReport` function
71
+ - `src/db.ts` — `findDuplicates`, `staleEntryCount`, `branchesInStore`
72
+ - `src/git.ts` — `listBranches`
73
+ - `bin/thatch` — `hygiene` and `reminder` subcommands
74
+
75
+ ## Key invariants
76
+
77
+ - The plugin never deletes memories. It surfaces problems; the agent tends the
78
+ store.
79
+ - Only non-zero signals are reported.
80
+ - Orphan check is skipped outside a git repo.
81
+ - Archived memories are excluded from staleness and duplicate checks.