@jeffober/thatch 0.1.18 → 0.1.20
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 +50 -224
- package/artifacts/skills/pr-description.md +140 -62
- package/artifacts/skills/thatch-code-review.md +10 -2
- package/artifacts/skills/thatch-review-synthesizer.md +29 -0
- package/artifacts/skills/ticket-description.md +53 -23
- package/bin/thatch +73 -7
- package/docs/dev/skills.md +5 -5
- package/docs/in-progress/prediction-engine.md +404 -0
- package/docs/qa/README.md +33 -1
- package/docs/qa/use-cases/README.md +4 -0
- package/docs/qa/use-cases/UC-021-prediction-autofire.md +21 -0
- package/docs/qa/use-cases/UC-022-prediction-dedup.md +19 -0
- package/docs/qa/use-cases/UC-023-prediction-confidence-model.md +23 -0
- package/docs/qa/use-cases/UC-024-prediction-delete.md +20 -0
- package/docs/user/README.md +6 -5
- package/package.json +1 -1
- package/src/db.ts +480 -0
- package/src/index.ts +54 -18
- package/src/prompts.ts +119 -11
- package/src/sideband.ts +51 -13
- package/src/tool-defs.ts +203 -0
package/src/db.ts
CHANGED
|
@@ -34,6 +34,46 @@ export interface DedupCandidate {
|
|
|
34
34
|
score: number;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export interface MatcherRow {
|
|
38
|
+
id: string;
|
|
39
|
+
store: string;
|
|
40
|
+
description: string;
|
|
41
|
+
embedding: Uint8Array | null;
|
|
42
|
+
model: string | null;
|
|
43
|
+
created_at: string;
|
|
44
|
+
updated_at: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PredictionRow {
|
|
48
|
+
id: string;
|
|
49
|
+
store: string;
|
|
50
|
+
statement: string;
|
|
51
|
+
rationale: string | null;
|
|
52
|
+
confidence: number;
|
|
53
|
+
confirm_count: number;
|
|
54
|
+
disconfirm_count: number;
|
|
55
|
+
created_at: string;
|
|
56
|
+
updated_at: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PredictionNudgeItem {
|
|
60
|
+
confidence: number;
|
|
61
|
+
evidence_count: number;
|
|
62
|
+
matcher_description: string;
|
|
63
|
+
statement: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ScoredPrediction {
|
|
67
|
+
matcher_id: string;
|
|
68
|
+
matcher_description: string;
|
|
69
|
+
prediction_id: string;
|
|
70
|
+
statement: string;
|
|
71
|
+
confidence: number;
|
|
72
|
+
evidence_count: number;
|
|
73
|
+
score: number;
|
|
74
|
+
rationale: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
/**
|
|
38
78
|
* SQLite-backed store for thatch. All stores live in a single database
|
|
39
79
|
* partitioned by a `store` column. Embeddings are raw Float32Array bytes
|
|
@@ -46,6 +86,12 @@ export class ThatchDB {
|
|
|
46
86
|
this.#db = new Database(path, { create: true });
|
|
47
87
|
this.#db.run("PRAGMA journal_mode = WAL");
|
|
48
88
|
this.#db.run("PRAGMA busy_timeout = 5000");
|
|
89
|
+
// Enforce FK constraints per connection. The prediction tables use
|
|
90
|
+
// ON DELETE CASCADE for edges and provenance; enabling this pragma
|
|
91
|
+
// ensures those cascades fire. Also enforces the entries table's
|
|
92
|
+
// existing FK to stores(name), which was declarative but unenforced
|
|
93
|
+
// before this branch.
|
|
94
|
+
this.#db.run("PRAGMA foreign_keys = ON");
|
|
49
95
|
this.#initSchema();
|
|
50
96
|
}
|
|
51
97
|
|
|
@@ -90,6 +136,57 @@ export class ThatchDB {
|
|
|
90
136
|
)
|
|
91
137
|
`);
|
|
92
138
|
|
|
139
|
+
this.#db.run(`
|
|
140
|
+
CREATE TABLE IF NOT EXISTS prediction_matchers (
|
|
141
|
+
id TEXT PRIMARY KEY,
|
|
142
|
+
store TEXT NOT NULL REFERENCES stores(name),
|
|
143
|
+
description TEXT NOT NULL,
|
|
144
|
+
embedding BLOB,
|
|
145
|
+
model TEXT,
|
|
146
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
147
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
148
|
+
)
|
|
149
|
+
`);
|
|
150
|
+
|
|
151
|
+
this.#db.run(`
|
|
152
|
+
CREATE TABLE IF NOT EXISTS predictions (
|
|
153
|
+
id TEXT PRIMARY KEY,
|
|
154
|
+
store TEXT NOT NULL REFERENCES stores(name),
|
|
155
|
+
statement TEXT NOT NULL,
|
|
156
|
+
rationale TEXT,
|
|
157
|
+
embedding BLOB,
|
|
158
|
+
model TEXT,
|
|
159
|
+
confidence REAL NOT NULL DEFAULT 0.5,
|
|
160
|
+
confirm_count REAL NOT NULL DEFAULT 0,
|
|
161
|
+
disconfirm_count REAL NOT NULL DEFAULT 0,
|
|
162
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
163
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
164
|
+
)
|
|
165
|
+
`);
|
|
166
|
+
|
|
167
|
+
this.#db.run(`
|
|
168
|
+
CREATE TABLE IF NOT EXISTS prediction_edges (
|
|
169
|
+
matcher_id TEXT NOT NULL,
|
|
170
|
+
prediction_id TEXT NOT NULL,
|
|
171
|
+
weight REAL NOT NULL DEFAULT 1.0,
|
|
172
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
173
|
+
PRIMARY KEY (matcher_id, prediction_id),
|
|
174
|
+
FOREIGN KEY (matcher_id) REFERENCES prediction_matchers(id) ON DELETE CASCADE,
|
|
175
|
+
FOREIGN KEY (prediction_id) REFERENCES predictions(id) ON DELETE CASCADE
|
|
176
|
+
)
|
|
177
|
+
`);
|
|
178
|
+
|
|
179
|
+
this.#db.run(`
|
|
180
|
+
CREATE TABLE IF NOT EXISTS prediction_provenance (
|
|
181
|
+
id TEXT PRIMARY KEY,
|
|
182
|
+
prediction_id TEXT NOT NULL,
|
|
183
|
+
signal TEXT NOT NULL,
|
|
184
|
+
detail TEXT,
|
|
185
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
186
|
+
FOREIGN KEY (prediction_id) REFERENCES predictions(id) ON DELETE CASCADE
|
|
187
|
+
)
|
|
188
|
+
`);
|
|
189
|
+
|
|
93
190
|
this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
|
|
94
191
|
|
|
95
192
|
this.#migrateColumns();
|
|
@@ -456,6 +553,383 @@ export class ThatchDB {
|
|
|
456
553
|
return new Set(rows.map((r: any) => [r.slug_a, r.slug_b].sort().join("|")));
|
|
457
554
|
}
|
|
458
555
|
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
// Prediction engine: matchers, predictions, edges, provenance
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
// Bayesian confidence model constants. The posterior is:
|
|
561
|
+
// p = (confirm_count + K * P0) / (confirm_count + disconfirm_count + K)
|
|
562
|
+
// K is the prior strength (pseudo-evidence count): K=5 means 5
|
|
563
|
+
// pseudo-evidence "anchors" the prior. P0 is the prior probability
|
|
564
|
+
// (p=0.5 = "no preference either way"). W_SOFT is the fractional
|
|
565
|
+
// weight for a soft (weak) signal: 0.25 means a soft disconfirm counts
|
|
566
|
+
// as 1/4 of a full disconfirm.
|
|
567
|
+
static readonly PREDICTION_K = 5;
|
|
568
|
+
static readonly PREDICTION_P0 = 0.5;
|
|
569
|
+
static readonly PREDICTION_W_SOFT = 0.25;
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Brute-force cosine search over the matchers table for auto-fire.
|
|
573
|
+
* Returns matchers ranked by similarity to the query embedding,
|
|
574
|
+
* filtered by a noise floor (cosine >= 0.01) to exclude near-zero
|
|
575
|
+
* and negative scores, and by model-space compatibility (dimension
|
|
576
|
+
* match). Callers apply the actual relevance threshold (0.45 for
|
|
577
|
+
* auto-fire in index.ts, caller-specified for sideband).
|
|
578
|
+
*/
|
|
579
|
+
findMatchers(
|
|
580
|
+
stores: string[],
|
|
581
|
+
queryEmbedding: Float32Array,
|
|
582
|
+
opts?: { limit?: number },
|
|
583
|
+
): { id: string; description: string; score: number }[] {
|
|
584
|
+
if (stores.length === 0) return [];
|
|
585
|
+
const limit = opts?.limit ?? 5;
|
|
586
|
+
const placeholders = stores.map(() => "?").join(", ");
|
|
587
|
+
const rows = this.#db
|
|
588
|
+
.query(
|
|
589
|
+
`SELECT id, description, embedding FROM prediction_matchers
|
|
590
|
+
WHERE store IN (${placeholders}) AND embedding IS NOT NULL`,
|
|
591
|
+
)
|
|
592
|
+
.all(...(stores as [string, ...string[]])) as any[];
|
|
593
|
+
|
|
594
|
+
const scored: { id: string; description: string; score: number }[] = [];
|
|
595
|
+
for (const r of rows) {
|
|
596
|
+
const emb = blobToVector(r.embedding);
|
|
597
|
+
if (emb.length !== queryEmbedding.length) continue;
|
|
598
|
+
const score = cosineSimilarity(queryEmbedding, emb);
|
|
599
|
+
if (score >= 0.01) {
|
|
600
|
+
scored.push({ id: r.id, description: r.description, score: Math.round(score * 1000) / 1000 });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
scored.sort((a, b) => b.score - a.score);
|
|
604
|
+
return scored.slice(0, limit);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Follows edges from matchers to scored predictions. Returns all
|
|
609
|
+
* predictions reachable from any matching matcher, ranked by
|
|
610
|
+
* cosine * weight * confidence.
|
|
611
|
+
*/
|
|
612
|
+
scorePredictions(
|
|
613
|
+
matchers: { id: string; description: string; score: number }[],
|
|
614
|
+
): ScoredPrediction[] {
|
|
615
|
+
if (matchers.length === 0) return [];
|
|
616
|
+
const matcherIds = matchers.map((m) => m.id);
|
|
617
|
+
const placeholders = matcherIds.map(() => "?").join(", ");
|
|
618
|
+
const rows = this.#db
|
|
619
|
+
.query(
|
|
620
|
+
`SELECT p.id, p.statement, p.rationale, p.confidence, p.confirm_count, p.disconfirm_count,
|
|
621
|
+
e.matcher_id, e.weight
|
|
622
|
+
FROM prediction_edges e
|
|
623
|
+
JOIN predictions p ON e.prediction_id = p.id
|
|
624
|
+
WHERE e.matcher_id IN (${placeholders})
|
|
625
|
+
ORDER BY p.statement`,
|
|
626
|
+
)
|
|
627
|
+
.all(...(matcherIds as [string, ...string[]])) as any[];
|
|
628
|
+
|
|
629
|
+
const matcherMap = new Map(matchers.map((m) => [m.id, m]));
|
|
630
|
+
const scored: ScoredPrediction[] = [];
|
|
631
|
+
for (const r of rows) {
|
|
632
|
+
const matcher = matcherMap.get(r.matcher_id);
|
|
633
|
+
if (!matcher) continue;
|
|
634
|
+
|
|
635
|
+
const confidence = r.confidence as number;
|
|
636
|
+
const evidence = Math.round(r.confirm_count + r.disconfirm_count);
|
|
637
|
+
const score = matcher.score * (r.weight as number) * confidence;
|
|
638
|
+
scored.push({
|
|
639
|
+
matcher_id: r.matcher_id,
|
|
640
|
+
matcher_description: matcher.description,
|
|
641
|
+
prediction_id: r.id,
|
|
642
|
+
statement: r.statement,
|
|
643
|
+
confidence: Math.round(confidence * 1000) / 1000,
|
|
644
|
+
evidence_count: evidence,
|
|
645
|
+
score: Math.round(score * 1000) / 1000,
|
|
646
|
+
rationale: r.rationale,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
scored.sort((a, b) => b.score - a.score);
|
|
650
|
+
|
|
651
|
+
// Dedup by prediction_id: multiple matchers may link to the same
|
|
652
|
+
// prediction via separate edges. Keep only the highest-scoring
|
|
653
|
+
// entry per prediction so the nudge doesn't repeat the same
|
|
654
|
+
// prediction with different matcher contexts.
|
|
655
|
+
const seen = new Set<string>();
|
|
656
|
+
return scored.filter((s) => {
|
|
657
|
+
if (seen.has(s.prediction_id)) return false;
|
|
658
|
+
seen.add(s.prediction_id);
|
|
659
|
+
return true;
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Full scoring pipeline for auto-fire and sideband: findMatchers,
|
|
665
|
+
* filter by threshold, scorePredictions (which includes dedup by
|
|
666
|
+
* prediction_id), slice, and map to PredictionNudgeItem. Shared by
|
|
667
|
+
* index.ts (auto-fire) and sideband.ts (MCP path) to prevent
|
|
668
|
+
* scoring-logic drift between the two host paths.
|
|
669
|
+
*/
|
|
670
|
+
scorePredictionNudge(
|
|
671
|
+
stores: string[],
|
|
672
|
+
embedding: Float32Array,
|
|
673
|
+
threshold: number,
|
|
674
|
+
limit = 5,
|
|
675
|
+
): PredictionNudgeItem[] {
|
|
676
|
+
const matchers = this.findMatchers(stores, embedding, { limit })
|
|
677
|
+
.filter((m) => m.score >= threshold);
|
|
678
|
+
if (matchers.length === 0) return [];
|
|
679
|
+
return this.scorePredictions(matchers)
|
|
680
|
+
.slice(0, limit)
|
|
681
|
+
.map((s) => ({
|
|
682
|
+
confidence: s.confidence,
|
|
683
|
+
evidence_count: s.evidence_count,
|
|
684
|
+
matcher_description: s.matcher_description,
|
|
685
|
+
statement: s.statement,
|
|
686
|
+
}));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Finds the nearest matcher by cosine similarity above a caller-
|
|
691
|
+
* specified threshold. Used for dedup at prediction_update time
|
|
692
|
+
* (threshold 0.85), not auto-fire (use findMatchers for that).
|
|
693
|
+
*/
|
|
694
|
+
findNearestMatcher(
|
|
695
|
+
store: string,
|
|
696
|
+
embedding: Float32Array,
|
|
697
|
+
threshold: number,
|
|
698
|
+
): MatcherRow | null {
|
|
699
|
+
const rows = this.#db
|
|
700
|
+
.query("SELECT id, store, description, embedding, model, created_at, updated_at FROM prediction_matchers WHERE store = ? AND embedding IS NOT NULL")
|
|
701
|
+
.all(store) as any[];
|
|
702
|
+
|
|
703
|
+
let best: { row: any; score: number } | null = null;
|
|
704
|
+
for (const r of rows) {
|
|
705
|
+
const emb = blobToVector(r.embedding);
|
|
706
|
+
if (emb.length !== embedding.length) continue;
|
|
707
|
+
const score = cosineSimilarity(embedding, emb);
|
|
708
|
+
if (score >= threshold && (!best || score > best.score)) {
|
|
709
|
+
best = { row: r, score };
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!best) return null;
|
|
713
|
+
return best.row as MatcherRow;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Creates a new matcher (context pattern) in the store, returning its id. */
|
|
717
|
+
createMatcher(
|
|
718
|
+
store: string,
|
|
719
|
+
description: string,
|
|
720
|
+
embedding: Float32Array,
|
|
721
|
+
model: string,
|
|
722
|
+
): string {
|
|
723
|
+
this.ensureStore(store);
|
|
724
|
+
const id = crypto.randomUUID();
|
|
725
|
+
const blob = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
726
|
+
const now = new Date().toISOString();
|
|
727
|
+
this.#db.run(
|
|
728
|
+
"INSERT INTO prediction_matchers (id, store, description, embedding, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
729
|
+
[id, store, description, blob, model, now, now] as any,
|
|
730
|
+
);
|
|
731
|
+
return id;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Finds the nearest prediction by cosine similarity across the
|
|
736
|
+
* entire store (not scoped to a single matcher's edges). This
|
|
737
|
+
* prevents duplicate predictions: when a new matcher is created,
|
|
738
|
+
* it can link to an existing prediction via a new edge rather
|
|
739
|
+
* than creating a second row with the same statement.
|
|
740
|
+
*/
|
|
741
|
+
findNearestPrediction(
|
|
742
|
+
store: string,
|
|
743
|
+
embedding: Float32Array,
|
|
744
|
+
threshold: number,
|
|
745
|
+
): PredictionRow | null {
|
|
746
|
+
const rows = this.#db
|
|
747
|
+
.query(
|
|
748
|
+
`SELECT id, store, statement, rationale, confidence, confirm_count, disconfirm_count, created_at, updated_at, embedding
|
|
749
|
+
FROM predictions
|
|
750
|
+
WHERE store = ? AND embedding IS NOT NULL`,
|
|
751
|
+
)
|
|
752
|
+
.all(store) as any[];
|
|
753
|
+
|
|
754
|
+
let best: { row: any; score: number } | null = null;
|
|
755
|
+
for (const r of rows) {
|
|
756
|
+
const emb = blobToVector(r.embedding);
|
|
757
|
+
if (emb.length !== embedding.length) continue;
|
|
758
|
+
const score = cosineSimilarity(embedding, emb);
|
|
759
|
+
if (score >= threshold && (!best || score > best.score)) {
|
|
760
|
+
best = { row: r, score };
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (!best) return null;
|
|
764
|
+
const { embedding: _, ...rest } = best.row;
|
|
765
|
+
return rest as PredictionRow;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Creates a new prediction, returning its id. Confidence is seeded
|
|
770
|
+
* at p0 (the population prior).
|
|
771
|
+
*/
|
|
772
|
+
createPrediction(
|
|
773
|
+
store: string,
|
|
774
|
+
statement: string,
|
|
775
|
+
rationale: string,
|
|
776
|
+
embedding: Float32Array,
|
|
777
|
+
model: string,
|
|
778
|
+
): string {
|
|
779
|
+
this.ensureStore(store);
|
|
780
|
+
const id = crypto.randomUUID();
|
|
781
|
+
const blob = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
782
|
+
const now = new Date().toISOString();
|
|
783
|
+
this.#db.run(
|
|
784
|
+
`INSERT INTO predictions (id, store, statement, rationale, embedding, model, confidence, confirm_count, disconfirm_count, created_at, updated_at)
|
|
785
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`,
|
|
786
|
+
[id, store, statement, rationale, blob, model, ThatchDB.PREDICTION_P0, now, now] as any,
|
|
787
|
+
);
|
|
788
|
+
return id;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Ensures an edge links a matcher to a prediction. Does not overwrite existing edge weight. */
|
|
792
|
+
createEdge(matcherId: string, predictionId: string, weight: number): void {
|
|
793
|
+
const now = new Date().toISOString();
|
|
794
|
+
this.#db.run(
|
|
795
|
+
`INSERT INTO prediction_edges (matcher_id, prediction_id, weight, created_at)
|
|
796
|
+
VALUES (?, ?, ?, ?)
|
|
797
|
+
ON CONFLICT(matcher_id, prediction_id) DO NOTHING`,
|
|
798
|
+
[matcherId, predictionId, weight, now] as any,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Adjusts a prediction's confidence by applying a signal, then
|
|
804
|
+
* recomputing the Bayesian posterior. Mirrors samskara's
|
|
805
|
+
* samskara_apply_evaluation: discount prior evidence is NOT applied
|
|
806
|
+
* in v1 (no wall-clock decay), so counts accumulate monotonically.
|
|
807
|
+
*
|
|
808
|
+
* Signal mapping: "confirm" adds 1 to confirm_count; "disconfirm"
|
|
809
|
+
* adds 1 to disconfirm_count; "soft" adds W_SOFT (0.25) to
|
|
810
|
+
* disconfirm_count. The asymmetry (soft is a weak disconfirm, not
|
|
811
|
+
* a weak confirm) is intentional: a soft signal means the user
|
|
812
|
+
* partially disagreed, not partially agreed. There is no soft
|
|
813
|
+
* confirm; use "confirm" for weak agreement.
|
|
814
|
+
*/
|
|
815
|
+
adjustConfidence(predictionId: string, signal: "confirm" | "disconfirm" | "soft"): void {
|
|
816
|
+
const deltaConfirm = signal === "confirm" ? 1 : 0;
|
|
817
|
+
const deltaDisconfirm = signal === "disconfirm" ? 1 : (signal === "soft" ? ThatchDB.PREDICTION_W_SOFT : 0);
|
|
818
|
+
const k = ThatchDB.PREDICTION_K;
|
|
819
|
+
const p0 = ThatchDB.PREDICTION_P0;
|
|
820
|
+
const now = new Date().toISOString();
|
|
821
|
+
// Atomic UPDATE: uses the OLD column values in the confidence
|
|
822
|
+
// expression, so (confirm_count + deltaConfirm) equals the new
|
|
823
|
+
// confirm_count. No read-modify-write race between connections.
|
|
824
|
+
this.#db.run(
|
|
825
|
+
`UPDATE predictions
|
|
826
|
+
SET confirm_count = confirm_count + ?,
|
|
827
|
+
disconfirm_count = disconfirm_count + ?,
|
|
828
|
+
confidence = (confirm_count + ? + ? * ?) / (confirm_count + ? + disconfirm_count + ? + ?),
|
|
829
|
+
updated_at = ?
|
|
830
|
+
WHERE id = ?`,
|
|
831
|
+
[deltaConfirm, deltaDisconfirm, deltaConfirm, k, p0, deltaConfirm, deltaDisconfirm, k, now, predictionId] as any,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Returns the population prior p0 for a store. Falls back to
|
|
837
|
+
* PREDICTION_P0 (0.5) when total evidence < 20; otherwise returns
|
|
838
|
+
* confirm_count / total_evidence. v2 feature: not yet wired into
|
|
839
|
+
* createPrediction, which uses flat PREDICTION_P0. v1 uses the
|
|
840
|
+
* flat prior until enough evidence exists to derive a population rate.
|
|
841
|
+
*/
|
|
842
|
+
populationP0(store: string): number {
|
|
843
|
+
const row = this.#db
|
|
844
|
+
.query(
|
|
845
|
+
`SELECT SUM(confirm_count) AS c, SUM(disconfirm_count) AS d FROM predictions WHERE store = ?`,
|
|
846
|
+
)
|
|
847
|
+
.get(store) as { c: number | null; d: number | null } | null;
|
|
848
|
+
const total = (row?.c ?? 0) + (row?.d ?? 0);
|
|
849
|
+
if (total < 20) return ThatchDB.PREDICTION_P0;
|
|
850
|
+
return row!.c! / total;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Returns a prediction's metadata by id. Does not return the
|
|
855
|
+
* embedding/model columns (creation-only, used by findNearestPrediction
|
|
856
|
+
* for dedup at write time, not needed for display or scoring).
|
|
857
|
+
*/
|
|
858
|
+
getPrediction(predictionId: string): PredictionRow | null {
|
|
859
|
+
const row = this.#db
|
|
860
|
+
.query(
|
|
861
|
+
"SELECT id, store, statement, rationale, confidence, confirm_count, disconfirm_count, created_at, updated_at FROM predictions WHERE id = ?",
|
|
862
|
+
)
|
|
863
|
+
.get(predictionId) as any;
|
|
864
|
+
if (!row) return null;
|
|
865
|
+
return row as PredictionRow;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Records a provenance entry (signal type + detail) for a prediction.
|
|
870
|
+
* Provenance is an audit trail for the inspector and agent.
|
|
871
|
+
*/
|
|
872
|
+
addProvenance(predictionId: string, signal: string, detail: string): void {
|
|
873
|
+
const id = crypto.randomUUID();
|
|
874
|
+
this.#db.run(
|
|
875
|
+
"INSERT INTO prediction_provenance (id, prediction_id, signal, detail) VALUES (?, ?, ?, ?)",
|
|
876
|
+
[id, predictionId, signal, detail] as any,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Returns recent provenance entries for a prediction (newest first). */
|
|
881
|
+
getProvenance(predictionId: string): { signal: string; detail: string | null; created_at: string }[] {
|
|
882
|
+
return this.#db
|
|
883
|
+
.query("SELECT signal, detail, created_at FROM prediction_provenance WHERE prediction_id = ? ORDER BY rowid DESC LIMIT 10")
|
|
884
|
+
.all(predictionId) as any[];
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Deletes a prediction. Edges and provenance cascade via FK ON DELETE CASCADE. */
|
|
888
|
+
deletePrediction(predictionId: string): boolean {
|
|
889
|
+
const result = this.#db.run("DELETE FROM predictions WHERE id = ?", [predictionId]);
|
|
890
|
+
return result.changes > 0;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Lists all predictions with their matchers, for the inspector. */
|
|
894
|
+
listPredictions(store: string): {
|
|
895
|
+
id: string;
|
|
896
|
+
statement: string;
|
|
897
|
+
rationale: string | null;
|
|
898
|
+
confidence: number;
|
|
899
|
+
evidence_count: number;
|
|
900
|
+
matchers: { id: string; description: string; weight: number }[];
|
|
901
|
+
}[] {
|
|
902
|
+
const predRows = this.#db
|
|
903
|
+
.query(
|
|
904
|
+
`SELECT id, statement, rationale, confidence, confirm_count, disconfirm_count
|
|
905
|
+
FROM predictions WHERE store = ? ORDER BY confidence DESC`,
|
|
906
|
+
)
|
|
907
|
+
.all(store) as any[];
|
|
908
|
+
|
|
909
|
+
return predRows.map((p) => {
|
|
910
|
+
const edgeRows = this.#db
|
|
911
|
+
.query(
|
|
912
|
+
`SELECT e.matcher_id, e.weight, m.description
|
|
913
|
+
FROM prediction_edges e
|
|
914
|
+
JOIN prediction_matchers m ON e.matcher_id = m.id
|
|
915
|
+
WHERE e.prediction_id = ?`,
|
|
916
|
+
)
|
|
917
|
+
.all(p.id) as any[];
|
|
918
|
+
return {
|
|
919
|
+
id: p.id,
|
|
920
|
+
statement: p.statement,
|
|
921
|
+
rationale: p.rationale,
|
|
922
|
+
confidence: Math.round(p.confidence * 1000) / 1000,
|
|
923
|
+
evidence_count: Math.round(p.confirm_count + p.disconfirm_count),
|
|
924
|
+
matchers: edgeRows.map((e) => ({
|
|
925
|
+
id: e.matcher_id,
|
|
926
|
+
description: e.description,
|
|
927
|
+
weight: e.weight,
|
|
928
|
+
})),
|
|
929
|
+
};
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
459
933
|
// ---------------------------------------------------------------------------
|
|
460
934
|
// Hygiene — signals for the session-start heartbeat. Staleness means
|
|
461
935
|
// neither written nor recalled since the cutoff; recall telemetry keeps
|
|
@@ -501,6 +975,12 @@ export class ThatchDB {
|
|
|
501
975
|
);
|
|
502
976
|
}
|
|
503
977
|
|
|
978
|
+
/** Runs fn inside a SQLite transaction (BEGIN/COMMIT/ROLLBACK). */
|
|
979
|
+
transaction<T>(fn: () => T): T {
|
|
980
|
+
const t = this.#db.transaction(fn);
|
|
981
|
+
return t();
|
|
982
|
+
}
|
|
983
|
+
|
|
504
984
|
close(): void {
|
|
505
985
|
this.#db.close();
|
|
506
986
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
sessionStartReminder,
|
|
11
11
|
recallNudge,
|
|
12
12
|
extractionNudge,
|
|
13
|
+
predictionNudge,
|
|
13
14
|
type NudgeMatch,
|
|
14
15
|
} from "./prompts";
|
|
15
16
|
import { ExtractionPipeline, type ToolInteraction } from "./extraction";
|
|
@@ -29,6 +30,11 @@ const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55
|
|
|
29
30
|
// like "yes" or "ok" match too broadly to be useful.
|
|
30
31
|
const MIN_PROMPT_LEN = 10;
|
|
31
32
|
|
|
33
|
+
// Minimum cosine score for the prediction auto-fire. Lower than
|
|
34
|
+
// RECALL_THRESHOLD because "this situation matches a known pattern" is
|
|
35
|
+
// a weaker signal than "this prompt relates to a stored memory."
|
|
36
|
+
const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.45");
|
|
37
|
+
|
|
32
38
|
export const server: Plugin = async ({ client, worktree }) => {
|
|
33
39
|
// The opencode server's cwd is wherever the server happened to start;
|
|
34
40
|
// `worktree` is the project this plugin instance actually serves.
|
|
@@ -69,7 +75,7 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
69
75
|
// (by reference identity), preserving interleaved-turn entries that arrived
|
|
70
76
|
// while the sub-agent was running. Without this, the child's memory_remember
|
|
71
77
|
// would drain the parent's ENTIRE buffer — silently dropping facts from
|
|
72
|
-
// any tool calls the parent made
|
|
78
|
+
// any tool calls the parent made concurrently with the sub-agent.
|
|
73
79
|
const parentSnapshots = new Map<string, ToolInteraction[]>();
|
|
74
80
|
|
|
75
81
|
// Skills always install to the global opencode config — installing into the
|
|
@@ -173,9 +179,14 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
173
179
|
// 4. Per-message nudge — two priority tiers:
|
|
174
180
|
// a. Extraction nudge: prior tool interactions are queued for fact
|
|
175
181
|
// extraction (carries the JSON payload for thatch-fact-extractor).
|
|
176
|
-
// b. Recall nudge:
|
|
177
|
-
//
|
|
178
|
-
//
|
|
182
|
+
// b. Recall + prediction nudge: when no extraction is pending, embed
|
|
183
|
+
// the user's prompt (shared embedding for both) and:
|
|
184
|
+
// - search memories by cosine (recall nudge)
|
|
185
|
+
// - search matchers by cosine, score predictions (prediction fire)
|
|
186
|
+
// Both fire independently; either, both, or neither may inject.
|
|
187
|
+
// The auto-fire reuses the embedding already computed for recall,
|
|
188
|
+
// adding only one more cosine scan against the matchers table.
|
|
189
|
+
// No extra model calls.
|
|
179
190
|
//
|
|
180
191
|
// Skipped during compaction: the agent can't call tools while generating
|
|
181
192
|
// a summary, so a nudge that says "use thatch_memory_recall" triggers a
|
|
@@ -212,23 +223,48 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
212
223
|
if (promptText.length < MIN_PROMPT_LEN) return;
|
|
213
224
|
|
|
214
225
|
const embedding = await model.queryEmbed(promptText);
|
|
215
|
-
const results = db.search([repo, "global"], embedding, { limit: 5 });
|
|
216
|
-
const matches: NudgeMatch[] = results
|
|
217
|
-
.filter((r) => r._score >= RECALL_THRESHOLD)
|
|
218
|
-
.map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
|
|
219
226
|
|
|
220
|
-
|
|
227
|
+
// Recall nudge: separate try/catch so a memory-search failure
|
|
228
|
+
// does not block the prediction fire (they share only the embedding).
|
|
229
|
+
try {
|
|
230
|
+
const results = db.search([repo, "global"], embedding, { limit: 5 });
|
|
231
|
+
const matches: NudgeMatch[] = results
|
|
232
|
+
.filter((r) => r._score >= RECALL_THRESHOLD)
|
|
233
|
+
.map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
|
|
221
234
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
if (matches.length > 0) {
|
|
236
|
+
output.parts.push({
|
|
237
|
+
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
238
|
+
sessionID: input.sessionID,
|
|
239
|
+
messageID: input.messageID ?? output.message.id,
|
|
240
|
+
type: "text",
|
|
241
|
+
text: recallNudge(matches),
|
|
242
|
+
synthetic: true,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error(`[thatch] recall nudge failed: ${err}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Prediction fire: independent of recall; a failure here does
|
|
250
|
+
// not affect the recall nudge that may have already been pushed.
|
|
251
|
+
try {
|
|
252
|
+
const predItems = db.scorePredictionNudge([repo, "global"], embedding, PREDICTION_THRESHOLD);
|
|
253
|
+
if (predItems.length > 0) {
|
|
254
|
+
output.parts.push({
|
|
255
|
+
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
256
|
+
sessionID: input.sessionID,
|
|
257
|
+
messageID: input.messageID ?? output.message.id,
|
|
258
|
+
type: "text",
|
|
259
|
+
text: predictionNudge(predItems),
|
|
260
|
+
synthetic: true,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.error(`[thatch] prediction nudge failed: ${err}`);
|
|
265
|
+
}
|
|
230
266
|
} catch (err) {
|
|
231
|
-
console.error(`[thatch]
|
|
267
|
+
console.error(`[thatch] nudge hook failed: ${err}`);
|
|
232
268
|
}
|
|
233
269
|
},
|
|
234
270
|
|