@jeffober/thatch 0.1.28 → 0.1.29
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/thatch +10 -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,374 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { blobToVector, cosineSimilarity } from "./vector-math";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bayesian confidence model constants shared by both engines. The
|
|
6
|
+
* posterior is:
|
|
7
|
+
* p = (confirm_count + K * P0) / (confirm_count + disconfirm_count + K)
|
|
8
|
+
* K is the prior strength (pseudo-evidence count): K=5 means 5
|
|
9
|
+
* pseudo-evidence "anchors" the prior. P0 is the prior probability
|
|
10
|
+
* (p=0.5 = "no preference either way"). W_SOFT is the fractional
|
|
11
|
+
* weight for a soft (weak) signal: 0.25 means a soft disconfirm
|
|
12
|
+
* counts as 1/4 of a full disconfirm.
|
|
13
|
+
*/
|
|
14
|
+
export const PREDICTION_K = 5;
|
|
15
|
+
export const PREDICTION_P0 = 0.5;
|
|
16
|
+
export const PREDICTION_W_SOFT = 0.25;
|
|
17
|
+
|
|
18
|
+
export interface ScoredItem {
|
|
19
|
+
matcher_id: string;
|
|
20
|
+
matcher_description: string;
|
|
21
|
+
item_id: string;
|
|
22
|
+
statement: string;
|
|
23
|
+
confidence: number;
|
|
24
|
+
evidence_count: number;
|
|
25
|
+
score: number;
|
|
26
|
+
rationale: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NudgeItem {
|
|
30
|
+
confidence: number;
|
|
31
|
+
evidence_count: number;
|
|
32
|
+
matcher_description: string;
|
|
33
|
+
statement: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Raw row from the items table (predictions or behaviors). Returned by
|
|
38
|
+
* findNearestItem and getItem. The wrapper engines cast this to their
|
|
39
|
+
* own PredictionRow / BehaviorRow types, which have the same shape.
|
|
40
|
+
*/
|
|
41
|
+
export interface ItemRow {
|
|
42
|
+
id: string;
|
|
43
|
+
store: string;
|
|
44
|
+
statement: string;
|
|
45
|
+
rationale: string | null;
|
|
46
|
+
confidence: number;
|
|
47
|
+
confirm_count: number;
|
|
48
|
+
disconfirm_count: number;
|
|
49
|
+
created_at: string;
|
|
50
|
+
updated_at: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface EngineConfig {
|
|
54
|
+
matchersTable: string;
|
|
55
|
+
itemsTable: string;
|
|
56
|
+
edgesTable: string;
|
|
57
|
+
provenanceTable: string;
|
|
58
|
+
/** FK column in edges and provenance tables that references the items table id */
|
|
59
|
+
itemForeignKey: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generic scoring engine shared by the prediction and behavior engines.
|
|
64
|
+
* Both use the same four-table shape (matchers, items, edges, provenance)
|
|
65
|
+
* with the same Bayesian confidence model. The only differences are
|
|
66
|
+
* table names and FK column names, which are fixed at module load time.
|
|
67
|
+
*
|
|
68
|
+
* Table names are interpolated into SQL strings, not parameterized with ?,
|
|
69
|
+
* because SQLite does not support parameterized table names. This is safe
|
|
70
|
+
* because the config values are compile-time constants, not user input.
|
|
71
|
+
*/
|
|
72
|
+
export class ScoringEngine {
|
|
73
|
+
#db: Database;
|
|
74
|
+
#cfg: EngineConfig;
|
|
75
|
+
|
|
76
|
+
constructor(db: Database, cfg: EngineConfig) {
|
|
77
|
+
this.#db = db;
|
|
78
|
+
this.#cfg = cfg;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
findMatchers(
|
|
82
|
+
stores: string[],
|
|
83
|
+
queryEmbedding: Float32Array,
|
|
84
|
+
opts?: { limit?: number },
|
|
85
|
+
): { id: string; description: string; score: number }[] {
|
|
86
|
+
if (stores.length === 0) return [];
|
|
87
|
+
const limit = opts?.limit ?? 5;
|
|
88
|
+
const placeholders = stores.map(() => "?").join(", ");
|
|
89
|
+
const rows = this.#db
|
|
90
|
+
.query(
|
|
91
|
+
`SELECT id, description, embedding FROM ${this.#cfg.matchersTable}
|
|
92
|
+
WHERE store IN (${placeholders}) AND embedding IS NOT NULL`,
|
|
93
|
+
)
|
|
94
|
+
.all(...(stores as [string, ...string[]])) as any[];
|
|
95
|
+
|
|
96
|
+
const scored: { id: string; description: string; score: number }[] = [];
|
|
97
|
+
for (const r of rows) {
|
|
98
|
+
const emb = blobToVector(r.embedding);
|
|
99
|
+
if (emb.length !== queryEmbedding.length) continue;
|
|
100
|
+
const score = cosineSimilarity(queryEmbedding, emb);
|
|
101
|
+
// Noise floor: filter near-zero and negative cosine scores.
|
|
102
|
+
// Callers apply the actual relevance threshold (e.g., 0.60).
|
|
103
|
+
if (score >= 0.01) {
|
|
104
|
+
scored.push({ id: r.id, description: r.description, score: Math.round(score * 1000) / 1000 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
scored.sort((a, b) => b.score - a.score);
|
|
108
|
+
return scored.slice(0, limit);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
scoreItems(
|
|
112
|
+
matchers: { id: string; description: string; score: number }[],
|
|
113
|
+
): ScoredItem[] {
|
|
114
|
+
if (matchers.length === 0) return [];
|
|
115
|
+
const matcherIds = matchers.map((m) => m.id);
|
|
116
|
+
const placeholders = matcherIds.map(() => "?").join(", ");
|
|
117
|
+
const { itemsTable, edgesTable, itemForeignKey } = this.#cfg;
|
|
118
|
+
const rows = this.#db
|
|
119
|
+
.query(
|
|
120
|
+
`SELECT p.id, p.statement, p.rationale, p.confidence, p.confirm_count, p.disconfirm_count,
|
|
121
|
+
e.matcher_id, e.weight
|
|
122
|
+
FROM ${edgesTable} e
|
|
123
|
+
JOIN ${itemsTable} p ON e.${itemForeignKey} = p.id
|
|
124
|
+
WHERE e.matcher_id IN (${placeholders})
|
|
125
|
+
ORDER BY p.statement`,
|
|
126
|
+
)
|
|
127
|
+
.all(...(matcherIds as [string, ...string[]])) as any[];
|
|
128
|
+
|
|
129
|
+
const matcherMap = new Map(matchers.map((m) => [m.id, m]));
|
|
130
|
+
const scored: ScoredItem[] = [];
|
|
131
|
+
for (const r of rows) {
|
|
132
|
+
const matcher = matcherMap.get(r.matcher_id);
|
|
133
|
+
if (!matcher) continue;
|
|
134
|
+
|
|
135
|
+
const confidence = r.confidence as number;
|
|
136
|
+
const evidence = Math.round(r.confirm_count + r.disconfirm_count);
|
|
137
|
+
const score = matcher.score * (r.weight as number) * confidence;
|
|
138
|
+
scored.push({
|
|
139
|
+
matcher_id: r.matcher_id,
|
|
140
|
+
matcher_description: matcher.description,
|
|
141
|
+
item_id: r.id,
|
|
142
|
+
statement: r.statement,
|
|
143
|
+
confidence: Math.round(confidence * 1000) / 1000,
|
|
144
|
+
evidence_count: evidence,
|
|
145
|
+
score: Math.round(score * 1000) / 1000,
|
|
146
|
+
rationale: r.rationale,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
scored.sort((a, b) => b.score - a.score);
|
|
150
|
+
|
|
151
|
+
// Dedup by item ID: multiple matchers may link to the same item
|
|
152
|
+
// via separate edges. Keep only the highest-scoring entry per item
|
|
153
|
+
// so the nudge does not repeat the same item with different matcher
|
|
154
|
+
// contexts.
|
|
155
|
+
const seen = new Set<string>();
|
|
156
|
+
return scored.filter((s) => {
|
|
157
|
+
if (seen.has(s.item_id)) return false;
|
|
158
|
+
seen.add(s.item_id);
|
|
159
|
+
return true;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
scoreNudge(
|
|
164
|
+
stores: string[],
|
|
165
|
+
embedding: Float32Array,
|
|
166
|
+
threshold: number,
|
|
167
|
+
limit = 5,
|
|
168
|
+
): NudgeItem[] {
|
|
169
|
+
const matchers = this.findMatchers(stores, embedding, { limit })
|
|
170
|
+
.filter((m) => m.score >= threshold);
|
|
171
|
+
if (matchers.length === 0) return [];
|
|
172
|
+
return this.scoreItems(matchers)
|
|
173
|
+
.slice(0, limit)
|
|
174
|
+
.map((s) => ({
|
|
175
|
+
confidence: s.confidence,
|
|
176
|
+
evidence_count: s.evidence_count,
|
|
177
|
+
matcher_description: s.matcher_description,
|
|
178
|
+
statement: s.statement,
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
findNearestMatcher(
|
|
183
|
+
store: string,
|
|
184
|
+
embedding: Float32Array,
|
|
185
|
+
threshold: number,
|
|
186
|
+
): { id: string; description: string } | null {
|
|
187
|
+
const rows = this.#db
|
|
188
|
+
.query(`SELECT id, description, embedding FROM ${this.#cfg.matchersTable} WHERE store = ? AND embedding IS NOT NULL`)
|
|
189
|
+
.all(store) as any[];
|
|
190
|
+
|
|
191
|
+
let best: { row: any; score: number } | null = null;
|
|
192
|
+
for (const r of rows) {
|
|
193
|
+
const emb = blobToVector(r.embedding);
|
|
194
|
+
if (emb.length !== embedding.length) continue;
|
|
195
|
+
const score = cosineSimilarity(embedding, emb);
|
|
196
|
+
if (score >= threshold && (!best || score > best.score)) {
|
|
197
|
+
best = { row: r, score };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!best) return null;
|
|
201
|
+
return { id: best.row.id, description: best.row.description };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
createMatcher(
|
|
205
|
+
store: string,
|
|
206
|
+
description: string,
|
|
207
|
+
embedding: Float32Array,
|
|
208
|
+
model: string,
|
|
209
|
+
): string {
|
|
210
|
+
this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES (?)", [store]);
|
|
211
|
+
const id = crypto.randomUUID();
|
|
212
|
+
const blob = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
213
|
+
const now = new Date().toISOString();
|
|
214
|
+
this.#db.run(
|
|
215
|
+
`INSERT INTO ${this.#cfg.matchersTable} (id, store, description, embedding, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
216
|
+
[id, store, description, blob, model, now, now] as any,
|
|
217
|
+
);
|
|
218
|
+
return id;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
findNearestItem(
|
|
222
|
+
store: string,
|
|
223
|
+
embedding: Float32Array,
|
|
224
|
+
threshold: number,
|
|
225
|
+
): ItemRow | null {
|
|
226
|
+
const { itemsTable } = this.#cfg;
|
|
227
|
+
const rows = this.#db
|
|
228
|
+
.query(
|
|
229
|
+
`SELECT id, store, statement, rationale, confidence, confirm_count, disconfirm_count, created_at, updated_at, embedding
|
|
230
|
+
FROM ${itemsTable}
|
|
231
|
+
WHERE store = ? AND embedding IS NOT NULL`,
|
|
232
|
+
)
|
|
233
|
+
.all(store) as any[];
|
|
234
|
+
|
|
235
|
+
let best: { row: any; score: number } | null = null;
|
|
236
|
+
for (const r of rows) {
|
|
237
|
+
const emb = blobToVector(r.embedding);
|
|
238
|
+
if (emb.length !== embedding.length) continue;
|
|
239
|
+
const score = cosineSimilarity(embedding, emb);
|
|
240
|
+
if (score >= threshold && (!best || score > best.score)) {
|
|
241
|
+
best = { row: r, score };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!best) return null;
|
|
245
|
+
const { embedding: _, ...rest } = best.row;
|
|
246
|
+
return rest;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
createItem(
|
|
250
|
+
store: string,
|
|
251
|
+
statement: string,
|
|
252
|
+
rationale: string,
|
|
253
|
+
embedding: Float32Array,
|
|
254
|
+
model: string,
|
|
255
|
+
): string {
|
|
256
|
+
this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES (?)", [store]);
|
|
257
|
+
const id = crypto.randomUUID();
|
|
258
|
+
const blob = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
259
|
+
const now = new Date().toISOString();
|
|
260
|
+
this.#db.run(
|
|
261
|
+
`INSERT INTO ${this.#cfg.itemsTable} (id, store, statement, rationale, embedding, model, confidence, confirm_count, disconfirm_count, created_at, updated_at)
|
|
262
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`,
|
|
263
|
+
[id, store, statement, rationale, blob, model, PREDICTION_P0, now, now] as any,
|
|
264
|
+
);
|
|
265
|
+
return id;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
createEdge(matcherId: string, itemId: string, weight: number): void {
|
|
269
|
+
const now = new Date().toISOString();
|
|
270
|
+
const { edgesTable, itemForeignKey } = this.#cfg;
|
|
271
|
+
this.#db.run(
|
|
272
|
+
`INSERT INTO ${edgesTable} (matcher_id, ${itemForeignKey}, weight, created_at)
|
|
273
|
+
VALUES (?, ?, ?, ?)
|
|
274
|
+
ON CONFLICT(matcher_id, ${itemForeignKey}) DO NOTHING`,
|
|
275
|
+
[matcherId, itemId, weight, now] as any,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
adjustConfidence(itemId: string, signal: "confirm" | "disconfirm" | "soft"): void {
|
|
280
|
+
// The asymmetry (soft is a weak disconfirm, not a weak confirm) is
|
|
281
|
+
// intentional: a soft signal means the user partially disagreed, not
|
|
282
|
+
// partially agreed. There is no soft confirm; use "confirm" for weak
|
|
283
|
+
// agreement.
|
|
284
|
+
const deltaConfirm = signal === "confirm" ? 1 : 0;
|
|
285
|
+
const deltaDisconfirm = signal === "disconfirm" ? 1 : (signal === "soft" ? PREDICTION_W_SOFT : 0);
|
|
286
|
+
const k = PREDICTION_K;
|
|
287
|
+
const p0 = PREDICTION_P0;
|
|
288
|
+
const now = new Date().toISOString();
|
|
289
|
+
// Atomic UPDATE: SQLite evaluates SET clause expressions using
|
|
290
|
+
// pre-update column values, so (confirm_count + ?) equals the new
|
|
291
|
+
// confirm_count in both the SET assignment and the confidence
|
|
292
|
+
// expression. No read-modify-write race between connections.
|
|
293
|
+
this.#db.run(
|
|
294
|
+
`UPDATE ${this.#cfg.itemsTable}
|
|
295
|
+
SET confirm_count = confirm_count + ?,
|
|
296
|
+
disconfirm_count = disconfirm_count + ?,
|
|
297
|
+
confidence = (confirm_count + ? + ? * ?) / (confirm_count + ? + disconfirm_count + ? + ?),
|
|
298
|
+
updated_at = ?
|
|
299
|
+
WHERE id = ?`,
|
|
300
|
+
[deltaConfirm, deltaDisconfirm, deltaConfirm, k, p0, deltaConfirm, deltaDisconfirm, k, now, itemId] as any,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
getItem(itemId: string): ItemRow | null {
|
|
305
|
+
const row = this.#db
|
|
306
|
+
.query(
|
|
307
|
+
`SELECT id, store, statement, rationale, confidence, confirm_count, disconfirm_count, created_at, updated_at FROM ${this.#cfg.itemsTable} WHERE id = ?`,
|
|
308
|
+
)
|
|
309
|
+
.get(itemId) as any;
|
|
310
|
+
if (!row) return null;
|
|
311
|
+
return row;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
addProvenance(itemId: string, signal: string, detail: string): void {
|
|
315
|
+
const id = crypto.randomUUID();
|
|
316
|
+
const { provenanceTable, itemForeignKey } = this.#cfg;
|
|
317
|
+
this.#db.run(
|
|
318
|
+
`INSERT INTO ${provenanceTable} (id, ${itemForeignKey}, signal, detail) VALUES (?, ?, ?, ?)`,
|
|
319
|
+
[id, itemId, signal, detail] as any,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
getProvenance(itemId: string): { signal: string; detail: string | null; created_at: string }[] {
|
|
324
|
+
const { provenanceTable, itemForeignKey } = this.#cfg;
|
|
325
|
+
return this.#db
|
|
326
|
+
.query(`SELECT signal, detail, created_at FROM ${provenanceTable} WHERE ${itemForeignKey} = ? ORDER BY rowid DESC LIMIT 10`)
|
|
327
|
+
.all(itemId) as any[];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
deleteItem(itemId: string): boolean {
|
|
331
|
+
const result = this.#db.run(`DELETE FROM ${this.#cfg.itemsTable} WHERE id = ?`, [itemId]);
|
|
332
|
+
return result.changes > 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
listItems(store: string): {
|
|
336
|
+
id: string;
|
|
337
|
+
statement: string;
|
|
338
|
+
rationale: string | null;
|
|
339
|
+
confidence: number;
|
|
340
|
+
evidence_count: number;
|
|
341
|
+
matchers: { id: string; description: string; weight: number }[];
|
|
342
|
+
}[] {
|
|
343
|
+
const { itemsTable, edgesTable, matchersTable, itemForeignKey } = this.#cfg;
|
|
344
|
+
const itemRows = this.#db
|
|
345
|
+
.query(
|
|
346
|
+
`SELECT id, statement, rationale, confidence, confirm_count, disconfirm_count
|
|
347
|
+
FROM ${itemsTable} WHERE store = ? ORDER BY confidence DESC`,
|
|
348
|
+
)
|
|
349
|
+
.all(store) as any[];
|
|
350
|
+
|
|
351
|
+
return itemRows.map((p) => {
|
|
352
|
+
const edgeRows = this.#db
|
|
353
|
+
.query(
|
|
354
|
+
`SELECT e.matcher_id, e.weight, m.description
|
|
355
|
+
FROM ${edgesTable} e
|
|
356
|
+
JOIN ${matchersTable} m ON e.matcher_id = m.id
|
|
357
|
+
WHERE e.${itemForeignKey} = ?`,
|
|
358
|
+
)
|
|
359
|
+
.all(p.id) as any[];
|
|
360
|
+
return {
|
|
361
|
+
id: p.id,
|
|
362
|
+
statement: p.statement,
|
|
363
|
+
rationale: p.rationale,
|
|
364
|
+
confidence: Math.round(p.confidence * 1000) / 1000,
|
|
365
|
+
evidence_count: Math.round(p.confirm_count + p.disconfirm_count),
|
|
366
|
+
matchers: edgeRows.map((e) => ({
|
|
367
|
+
id: e.matcher_id,
|
|
368
|
+
description: e.description,
|
|
369
|
+
weight: e.weight,
|
|
370
|
+
})),
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|