@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.
- package/README.md +24 -10
- package/artifacts/skills/thatch-code-review.md +9 -7
- package/artifacts/skills/thatch-review-economy.md +97 -0
- package/artifacts/skills/thatch-review-followup.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +15 -5
- package/bin/qa-run +38 -0
- package/bin/release +21 -0
- package/bin/thatch +17 -5
- package/docs/dev/README.md +71 -18
- package/docs/dev/features/README.md +33 -0
- package/docs/dev/features/behavior-engine.md +271 -0
- package/docs/dev/features/cicd.md +89 -0
- package/docs/dev/features/cli.md +81 -0
- package/docs/dev/features/compaction-recovery.md +48 -0
- package/docs/dev/features/database.md +244 -0
- package/docs/dev/features/deduplication.md +102 -0
- package/docs/dev/features/extraction.md +253 -0
- package/docs/dev/features/hygiene.md +81 -0
- package/docs/dev/features/memory-store.md +154 -0
- package/docs/dev/features/multi-host.md +228 -0
- package/docs/dev/features/nudge-pipeline.md +282 -0
- package/docs/dev/features/prediction-engine.md +253 -0
- package/docs/dev/features/qa-system.md +205 -0
- package/docs/dev/features/session-lifecycle.md +110 -0
- package/docs/dev/features/setup.md +199 -0
- package/docs/dev/features/sideband.md +104 -0
- package/docs/dev/gotchas.md +3 -3
- package/docs/dev/mcp-parity.md +6 -4
- package/docs/dev/setup-and-hooks.md +4 -4
- package/docs/dev/skills.md +10 -8
- package/docs/plans/README.md +2 -2
- package/docs/user/README.md +58 -20
- package/docs/user/behavior-engine.md +85 -0
- package/docs/user/cli.md +98 -0
- package/docs/user/code-review.md +73 -0
- package/docs/user/deduplication.md +70 -0
- package/docs/user/default-behaviors.md +75 -0
- package/docs/user/extraction.md +74 -0
- package/docs/user/hygiene.md +76 -0
- package/docs/user/memory.md +81 -0
- package/docs/user/prediction-engine.md +87 -0
- package/docs/user/setup.md +110 -0
- package/docs/user/skills.md +97 -0
- package/package.json +2 -2
- package/src/behavior.ts +120 -0
- package/src/db.ts +160 -417
- package/src/embeddings.ts +0 -41
- package/src/extraction.ts +2 -2
- package/src/git.ts +3 -1
- package/src/index.ts +55 -9
- package/src/mcp.ts +30 -1
- package/src/prediction.ts +127 -0
- package/src/prompts.ts +135 -188
- package/src/scoring-engine.ts +374 -0
- package/src/seed-behaviors.ts +339 -0
- package/src/setup.ts +2 -6
- package/src/sideband.ts +36 -8
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +172 -25
- package/src/vector-math.ts +30 -0
- 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.
|